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,489 @@
1
+ """Utility helpers for the ChatGPT OAuth plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import datetime
7
+ import hashlib
8
+ import json
9
+ import logging
10
+ import secrets
11
+ import time
12
+ from dataclasses import dataclass
13
+ from typing import Any, Dict, List, Optional
14
+ from urllib.parse import parse_qs as urllib_parse_qs
15
+ from urllib.parse import urlencode, urlparse
16
+
17
+ import requests
18
+
19
+ from .config import (
20
+ CHATGPT_OAUTH_CONFIG,
21
+ get_chatgpt_models_path,
22
+ get_token_storage_path,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ @dataclass
29
+ class OAuthContext:
30
+ """Runtime state for an in-progress OAuth flow."""
31
+
32
+ state: str
33
+ code_verifier: str
34
+ code_challenge: str
35
+ created_at: float
36
+ redirect_uri: Optional[str] = None
37
+ expires_at: Optional[float] = None # Add expiration time
38
+
39
+ def is_expired(self) -> bool:
40
+ """Check if this OAuth context has expired."""
41
+ if self.expires_at is None:
42
+ # Default 5 minute expiration if not set
43
+ return time.time() - self.created_at > 300
44
+ return time.time() > self.expires_at
45
+
46
+
47
+ def _urlsafe_b64encode(data: bytes) -> str:
48
+ return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
49
+
50
+
51
+ def _generate_code_verifier() -> str:
52
+ return secrets.token_hex(64)
53
+
54
+
55
+ def _compute_code_challenge(code_verifier: str) -> str:
56
+ digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
57
+ return _urlsafe_b64encode(digest)
58
+
59
+
60
+ def prepare_oauth_context() -> OAuthContext:
61
+ """Create a fresh OAuth PKCE context."""
62
+ state = secrets.token_hex(32)
63
+ code_verifier = _generate_code_verifier()
64
+ code_challenge = _compute_code_challenge(code_verifier)
65
+
66
+ # Set expiration 4 minutes from now (OpenAI sessions are short)
67
+ expires_at = time.time() + 240
68
+
69
+ return OAuthContext(
70
+ state=state,
71
+ code_verifier=code_verifier,
72
+ code_challenge=code_challenge,
73
+ created_at=time.time(),
74
+ expires_at=expires_at,
75
+ )
76
+
77
+
78
+ def assign_redirect_uri(context: OAuthContext, port: int) -> str:
79
+ """Assign redirect URI for the given OAuth context."""
80
+ if context is None:
81
+ raise RuntimeError("OAuth context cannot be None")
82
+ host = CHATGPT_OAUTH_CONFIG["redirect_host"].rstrip("/")
83
+ path = CHATGPT_OAUTH_CONFIG["redirect_path"].lstrip("/")
84
+ required_port = CHATGPT_OAUTH_CONFIG.get("required_port")
85
+ if required_port and port != required_port:
86
+ raise RuntimeError(
87
+ f"OAuth flow must use port {required_port}; attempted to assign port {port}"
88
+ )
89
+ redirect_uri = f"{host}:{port}/{path}"
90
+ context.redirect_uri = redirect_uri
91
+ return redirect_uri
92
+
93
+
94
+ def build_authorization_url(context: OAuthContext) -> str:
95
+ """Return the OpenAI authorization URL with PKCE parameters."""
96
+ if not context.redirect_uri:
97
+ raise RuntimeError("Redirect URI has not been assigned for this OAuth context")
98
+
99
+ params = {
100
+ "response_type": "code",
101
+ "client_id": CHATGPT_OAUTH_CONFIG["client_id"],
102
+ "redirect_uri": context.redirect_uri,
103
+ "scope": CHATGPT_OAUTH_CONFIG["scope"],
104
+ "code_challenge": context.code_challenge,
105
+ "code_challenge_method": "S256",
106
+ "id_token_add_organizations": "true",
107
+ "codex_cli_simplified_flow": "true",
108
+ "state": context.state,
109
+ }
110
+ return f"{CHATGPT_OAUTH_CONFIG['auth_url']}?{urlencode(params)}"
111
+
112
+
113
+ def parse_authorization_error(url: str) -> Optional[str]:
114
+ """Parse error from OAuth callback URL."""
115
+ try:
116
+ parsed = urlparse(url)
117
+ params = urllib_parse_qs(parsed.query)
118
+ error = params.get("error", [None])[0]
119
+ error_description = params.get("error_description", [None])[0]
120
+ if error:
121
+ return f"{error}: {error_description or 'Unknown error'}"
122
+ except Exception as exc:
123
+ logger.error("Failed to parse OAuth error: %s", exc)
124
+ return None
125
+
126
+
127
+ def parse_jwt_claims(token: str) -> Optional[Dict[str, Any]]:
128
+ """Parse JWT token to extract claims."""
129
+ if not token or token.count(".") != 2:
130
+ return None
131
+ try:
132
+ _, payload, _ = token.split(".")
133
+ padded = payload + "=" * (-len(payload) % 4)
134
+ data = base64.urlsafe_b64decode(padded.encode())
135
+ return json.loads(data.decode())
136
+ except Exception as exc:
137
+ logger.error("Failed to parse JWT: %s", exc)
138
+ return None
139
+
140
+
141
+ def load_stored_tokens() -> Optional[Dict[str, Any]]:
142
+ try:
143
+ token_path = get_token_storage_path()
144
+ if token_path.exists():
145
+ with open(token_path, "r", encoding="utf-8") as handle:
146
+ return json.load(handle)
147
+ except Exception as exc:
148
+ logger.error("Failed to load tokens: %s", exc)
149
+ return None
150
+
151
+
152
+ def get_valid_access_token() -> Optional[str]:
153
+ """Get a valid access token, refreshing if expired.
154
+
155
+ Returns:
156
+ Valid access token string, or None if not authenticated or refresh failed.
157
+ """
158
+ tokens = load_stored_tokens()
159
+ if not tokens:
160
+ logger.debug("No stored ChatGPT OAuth tokens found")
161
+ return None
162
+
163
+ access_token = tokens.get("access_token")
164
+ if not access_token:
165
+ logger.debug("No access_token in stored tokens")
166
+ return None
167
+
168
+ # Check if token is expired by parsing JWT claims
169
+ claims = parse_jwt_claims(access_token)
170
+ if claims:
171
+ exp = claims.get("exp")
172
+ if exp and isinstance(exp, (int, float)):
173
+ # Add 30 second buffer before expiry
174
+ if time.time() > exp - 30:
175
+ logger.info("ChatGPT OAuth token expired, attempting refresh")
176
+ refreshed = refresh_access_token()
177
+ if refreshed:
178
+ return refreshed
179
+ logger.warning("Token refresh failed")
180
+ return None
181
+
182
+ return access_token
183
+
184
+
185
+ def refresh_access_token() -> Optional[str]:
186
+ """Refresh the access token using the refresh token.
187
+
188
+ Returns:
189
+ New access token if refresh succeeded, None otherwise.
190
+ """
191
+ tokens = load_stored_tokens()
192
+ if not tokens:
193
+ return None
194
+
195
+ refresh_token = tokens.get("refresh_token")
196
+ if not refresh_token:
197
+ logger.debug("No refresh_token available")
198
+ return None
199
+
200
+ payload = {
201
+ "grant_type": "refresh_token",
202
+ "refresh_token": refresh_token,
203
+ "client_id": CHATGPT_OAUTH_CONFIG["client_id"],
204
+ }
205
+
206
+ headers = {
207
+ "Content-Type": "application/x-www-form-urlencoded",
208
+ }
209
+
210
+ try:
211
+ response = requests.post(
212
+ CHATGPT_OAUTH_CONFIG["token_url"],
213
+ data=payload,
214
+ headers=headers,
215
+ timeout=30,
216
+ )
217
+
218
+ if response.status_code == 200:
219
+ new_tokens = response.json()
220
+ # Merge with existing tokens (preserve account_id, etc.)
221
+ tokens.update(
222
+ {
223
+ "access_token": new_tokens.get("access_token"),
224
+ "refresh_token": new_tokens.get("refresh_token", refresh_token),
225
+ "id_token": new_tokens.get("id_token", tokens.get("id_token")),
226
+ "last_refresh": datetime.datetime.now(datetime.timezone.utc)
227
+ .isoformat()
228
+ .replace("+00:00", "Z"),
229
+ }
230
+ )
231
+ if save_tokens(tokens):
232
+ logger.info("Successfully refreshed ChatGPT OAuth token")
233
+ return tokens["access_token"]
234
+ else:
235
+ logger.error(
236
+ "Token refresh failed: %s - %s", response.status_code, response.text
237
+ )
238
+ except Exception as exc:
239
+ logger.error("Token refresh error: %s", exc)
240
+
241
+ return None
242
+
243
+
244
+ def save_tokens(tokens: Dict[str, Any]) -> bool:
245
+ if tokens is None:
246
+ raise TypeError("tokens cannot be None")
247
+ try:
248
+ token_path = get_token_storage_path()
249
+ with open(token_path, "w", encoding="utf-8") as handle:
250
+ json.dump(tokens, handle, indent=2)
251
+ token_path.chmod(0o600)
252
+ return True
253
+ except Exception as exc:
254
+ logger.error("Failed to save tokens: %s", exc)
255
+ return False
256
+
257
+
258
+ def load_chatgpt_models() -> Dict[str, Any]:
259
+ try:
260
+ models_path = get_chatgpt_models_path()
261
+ if models_path.exists():
262
+ with open(models_path, "r", encoding="utf-8") as handle:
263
+ return json.load(handle)
264
+ except Exception as exc:
265
+ logger.error("Failed to load ChatGPT models: %s", exc)
266
+ return {}
267
+
268
+
269
+ def save_chatgpt_models(models: Dict[str, Any]) -> bool:
270
+ try:
271
+ models_path = get_chatgpt_models_path()
272
+ with open(models_path, "w", encoding="utf-8") as handle:
273
+ json.dump(models, handle, indent=2)
274
+ return True
275
+ except Exception as exc:
276
+ logger.error("Failed to save ChatGPT models: %s", exc)
277
+ return False
278
+
279
+
280
+ def exchange_code_for_tokens(
281
+ auth_code: str, context: OAuthContext
282
+ ) -> Optional[Dict[str, Any]]:
283
+ """Exchange authorization code for access tokens."""
284
+ if not context.redirect_uri:
285
+ raise RuntimeError("Redirect URI missing from OAuth context")
286
+
287
+ if context.is_expired():
288
+ logger.error("OAuth context expired, cannot exchange code")
289
+ return None
290
+
291
+ payload = {
292
+ "grant_type": "authorization_code",
293
+ "code": auth_code,
294
+ "redirect_uri": context.redirect_uri,
295
+ "client_id": CHATGPT_OAUTH_CONFIG["client_id"],
296
+ "code_verifier": context.code_verifier,
297
+ }
298
+
299
+ headers = {
300
+ "Content-Type": "application/x-www-form-urlencoded",
301
+ }
302
+
303
+ logger.info("Exchanging code for tokens: %s", CHATGPT_OAUTH_CONFIG["token_url"])
304
+ try:
305
+ response = requests.post(
306
+ CHATGPT_OAUTH_CONFIG["token_url"],
307
+ data=payload,
308
+ headers=headers,
309
+ timeout=30,
310
+ )
311
+ logger.info("Token exchange response: %s", response.status_code)
312
+ if response.status_code == 200:
313
+ token_data = response.json()
314
+ # Add timestamp
315
+ token_data["last_refresh"] = (
316
+ datetime.datetime.now(datetime.timezone.utc)
317
+ .isoformat()
318
+ .replace("+00:00", "Z")
319
+ )
320
+ return token_data
321
+ else:
322
+ logger.error(
323
+ "Token exchange failed: %s - %s",
324
+ response.status_code,
325
+ response.text,
326
+ )
327
+ # Try to parse OAuth error
328
+ if response.headers.get("content-type", "").startswith("application/json"):
329
+ try:
330
+ error_data = response.json()
331
+ if "error" in error_data:
332
+ logger.error(
333
+ "OAuth error: %s",
334
+ error_data.get("error_description", error_data["error"]),
335
+ )
336
+ except Exception:
337
+ pass
338
+ except Exception as exc:
339
+ logger.error("Token exchange error: %s", exc)
340
+ return None
341
+
342
+
343
+ # Default models available via ChatGPT Codex API
344
+ # These are the known models that work with ChatGPT OAuth tokens
345
+ # Based on codex-rs CLI and shell-scripts/codex-call.sh
346
+ DEFAULT_CODEX_MODELS = [
347
+ "gpt-5.2",
348
+ "gpt-5.2-codex",
349
+ ]
350
+
351
+
352
+ def fetch_chatgpt_models(access_token: str, account_id: str) -> Optional[List[str]]:
353
+ """Fetch available models from ChatGPT Codex API.
354
+
355
+ Attempts to fetch models from the API, but falls back to a default list
356
+ of known Codex-compatible models if the API is unavailable.
357
+
358
+ Args:
359
+ access_token: OAuth access token for authentication
360
+ account_id: ChatGPT account ID (required for the API)
361
+
362
+ Returns:
363
+ List of model IDs, or default list if API fails
364
+ """
365
+ import platform
366
+
367
+ # Build the models URL with client version
368
+ client_version = CHATGPT_OAUTH_CONFIG.get("client_version", "0.72.0")
369
+ base_url = CHATGPT_OAUTH_CONFIG["api_base_url"].rstrip("/")
370
+ models_url = f"{base_url}/models"
371
+
372
+ # Build User-Agent to match codex-rs CLI format
373
+ originator = CHATGPT_OAUTH_CONFIG.get("originator", "codex_cli_rs")
374
+ os_name = platform.system()
375
+ if os_name == "Darwin":
376
+ os_name = "Mac OS"
377
+ os_version = platform.release()
378
+ arch = platform.machine()
379
+ user_agent = (
380
+ f"{originator}/{client_version} ({os_name} {os_version}; {arch}) "
381
+ "Terminal_Codex_CLI"
382
+ )
383
+
384
+ headers = {
385
+ "Authorization": f"Bearer {access_token}",
386
+ "ChatGPT-Account-Id": account_id,
387
+ "User-Agent": user_agent,
388
+ "originator": originator,
389
+ "Accept": "application/json",
390
+ }
391
+
392
+ # Query params
393
+ params = {"client_version": client_version}
394
+
395
+ try:
396
+ response = requests.get(models_url, headers=headers, params=params, timeout=30)
397
+
398
+ if response.status_code == 200:
399
+ # Parse JSON response
400
+ try:
401
+ data = response.json()
402
+ # The response has a "models" key with list of model objects
403
+ if "models" in data and isinstance(data["models"], list):
404
+ models = []
405
+ for model in data["models"]:
406
+ if model is None:
407
+ continue
408
+ model_id = (
409
+ model.get("slug") or model.get("id") or model.get("name")
410
+ )
411
+ if model_id:
412
+ models.append(model_id)
413
+ if models:
414
+ return models
415
+ except (json.JSONDecodeError, ValueError) as exc:
416
+ logger.warning("Failed to parse models response: %s", exc)
417
+
418
+ # API didn't return valid models, use default list
419
+ logger.info(
420
+ "Models endpoint returned %d, using default model list",
421
+ response.status_code,
422
+ )
423
+
424
+ except requests.exceptions.Timeout:
425
+ logger.warning("Timeout fetching models, using default list")
426
+ except requests.exceptions.RequestException as exc:
427
+ logger.warning("Network error fetching models: %s, using default list", exc)
428
+ except Exception as exc:
429
+ logger.warning("Error fetching models: %s, using default list", exc)
430
+
431
+ # Return default models when API fails
432
+ logger.info("Using default Codex models: %s", DEFAULT_CODEX_MODELS)
433
+ return DEFAULT_CODEX_MODELS
434
+
435
+
436
+ def add_models_to_extra_config(models: List[str]) -> bool:
437
+ """Add ChatGPT models to chatgpt_models.json configuration."""
438
+ try:
439
+ chatgpt_models = load_chatgpt_models()
440
+ added = 0
441
+ for model_name in models:
442
+ prefixed = f"{CHATGPT_OAUTH_CONFIG['prefix']}{model_name}"
443
+
444
+ # Determine supported settings based on model type
445
+ # All GPT-5.x models support reasoning_effort and verbosity
446
+ supported_settings = ["reasoning_effort", "verbosity"]
447
+
448
+ # Only codex models support xhigh reasoning effort
449
+ # Regular gpt-5.2 is capped at "high"
450
+ is_codex = "codex" in model_name.lower()
451
+
452
+ chatgpt_models[prefixed] = {
453
+ "type": "chatgpt_oauth",
454
+ "name": model_name,
455
+ "custom_endpoint": {
456
+ # Codex API uses chatgpt.com/backend-api/codex, not api.openai.com
457
+ "url": CHATGPT_OAUTH_CONFIG["api_base_url"],
458
+ },
459
+ "context_length": CHATGPT_OAUTH_CONFIG["default_context_length"],
460
+ "oauth_source": "chatgpt-oauth-plugin",
461
+ "supported_settings": supported_settings,
462
+ "supports_xhigh_reasoning": is_codex,
463
+ }
464
+ added += 1
465
+ if save_chatgpt_models(chatgpt_models):
466
+ logger.info("Added %s ChatGPT models", added)
467
+ return True
468
+ except Exception as exc:
469
+ logger.error("Error adding models to config: %s", exc)
470
+ return False
471
+
472
+
473
+ def remove_chatgpt_models() -> int:
474
+ """Remove ChatGPT OAuth models from chatgpt_models.json."""
475
+ try:
476
+ chatgpt_models = load_chatgpt_models()
477
+ to_remove = [
478
+ name
479
+ for name, config in chatgpt_models.items()
480
+ if config.get("oauth_source") == "chatgpt-oauth-plugin"
481
+ ]
482
+ for model_name in to_remove:
483
+ chatgpt_models.pop(model_name, None)
484
+ # Always save, even if no models were removed (to match test expectations)
485
+ if save_chatgpt_models(chatgpt_models):
486
+ return len(to_remove)
487
+ except Exception as exc:
488
+ logger.error("Error removing ChatGPT models: %s", exc)
489
+ return 0
@@ -0,0 +1,167 @@
1
+ # Claude Code OAuth Plugin
2
+
3
+ This plugin adds OAuth authentication for Claude Code to Code Puppy, automatically importing available models into your configuration.
4
+
5
+ ## Features
6
+
7
+ - **OAuth Authentication**: Secure OAuth flow for Claude Code using PKCE
8
+ - **Automatic Model Discovery**: Fetches available models from the Claude API once authenticated
9
+ - **Model Registration**: Automatically adds models to `extra_models.json` with the `claude-code-` prefix
10
+ - **Token Management**: Secure storage of OAuth tokens in the Code Puppy config directory
11
+ - **Browser Integration**: Launches the Claude OAuth consent flow automatically
12
+ - **Callback Capture**: Listens on localhost to receive and process the OAuth redirect
13
+
14
+ ## Commands
15
+
16
+ ### `/claude-code-auth`
17
+ Authenticate with Claude Code via OAuth and import available models.
18
+
19
+ This will:
20
+ 1. Launch the Claude OAuth consent flow in your browser
21
+ 2. Walk you through approving access for the shared `claude-cli` client
22
+ 3. Capture the redirect from Claude in a temporary local callback server
23
+ 4. Exchange the returned code for access tokens and store them securely
24
+ 5. Fetch available models from Claude Code and add them to your configuration
25
+
26
+ ### `/claude-code-status`
27
+ Check Claude Code OAuth authentication status and configured models.
28
+
29
+ Shows:
30
+ - Current authentication status
31
+ - Token expiry information (if available)
32
+ - Number and names of configured Claude Code models
33
+
34
+ ### `/claude-code-logout`
35
+ Remove Claude Code OAuth tokens and imported models.
36
+
37
+ This will:
38
+ 1. Remove stored OAuth tokens
39
+ 2. Remove all Claude Code models from `extra_models.json`
40
+
41
+ ## Setup
42
+
43
+ ### Prerequisites
44
+
45
+ 1. **Claude account** with access to the Claude Console developer settings
46
+ 2. **Browser access** to generate authorization codes
47
+
48
+ ### Configuration
49
+
50
+ The plugin ships with sensible defaults in `config.py`:
51
+
52
+ ```python
53
+ CLAUDE_CODE_OAUTH_CONFIG = {
54
+ "auth_url": "https://claude.ai/oauth/authorize",
55
+ "token_url": "https://claude.ai/api/oauth/token",
56
+ "api_base_url": "https://api.anthropic.com",
57
+ "client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
58
+ "scope": "org:create_api_key user:profile user:inference",
59
+ "redirect_host": "http://localhost",
60
+ "redirect_path": "callback",
61
+ "callback_port_range": (8765, 8795),
62
+ "callback_timeout": 180,
63
+ "prefix": "claude-code-",
64
+ "default_context_length": 200000,
65
+ "api_key_env_var": "CLAUDE_CODE_ACCESS_TOKEN",
66
+ }
67
+ ```
68
+
69
+ These values mirror the public client used by llxprt-code. Adjust only if Anthropic changes their configuration.
70
+
71
+ ### Environment Variables
72
+
73
+ After authentication, the models will reference:
74
+ - `CLAUDE_CODE_ACCESS_TOKEN`: Automatically written by the plugin
75
+
76
+ ## Usage Example
77
+
78
+ ```bash
79
+ # Authenticate with Claude Code
80
+ /claude-code-auth
81
+
82
+ # Check status
83
+ /claude-code-status
84
+
85
+ # Use a Claude Code model
86
+ /set model claude-code-claude-3-5-sonnet-20241022
87
+
88
+ # When done, logout
89
+ /claude-code-logout
90
+ ```
91
+
92
+ ## Model Configuration
93
+
94
+ After authentication, models will be added to `~/.code_puppy/extra_models.json`:
95
+
96
+ ```json
97
+ {
98
+ "claude-code-claude-3-5-sonnet-20241022": {
99
+ "type": "anthropic",
100
+ "name": "claude-3-5-sonnet-20241022",
101
+ "custom_endpoint": {
102
+ "url": "https://api.anthropic.com",
103
+ "api_key": "$CLAUDE_CODE_ACCESS_TOKEN"
104
+ },
105
+ "context_length": 200000,
106
+ "oauth_source": "claude-code-plugin"
107
+ }
108
+ }
109
+ ```
110
+
111
+ ## Security
112
+
113
+ - **Token Storage**: Tokens are saved to `~/.code_puppy/claude_code_oauth.json` with `0o600` permissions
114
+ - **PKCE Support**: Uses Proof Key for Code Exchange for enhanced security
115
+ - **State Validation**: Checks the returned state (if provided) to guard against CSRF
116
+ - **HTTPS Only**: All OAuth communications use HTTPS endpoints
117
+
118
+ ## Troubleshooting
119
+
120
+ ### Browser doesn't open
121
+ - Manually visit the URL shown in the output
122
+ - Check that a default browser is configured
123
+
124
+ ### Authentication fails
125
+ - Ensure the browser completed the redirect back to Code Puppy (no pop-up blockers)
126
+ - Retry if the window shows an error; codes expire quickly
127
+ - Confirm network access to `claude.ai`
128
+
129
+ ### Models not showing up
130
+ - Claude may not return the model list for your account; verify access manually
131
+ - Check `/claude-code-status` to confirm authentication succeeded
132
+
133
+ ## Development
134
+
135
+ ### File Structure
136
+
137
+ ```
138
+ claude_code_oauth/
139
+ ├── __init__.py
140
+ ├── register_callbacks.py # Main plugin logic and command handlers
141
+ ├── config.py # Configuration settings
142
+ ├── utils.py # OAuth helpers and file operations
143
+ ├── README.md # This file
144
+ ├── SETUP.md # Quick setup guide
145
+ └── test_plugin.py # Manual test helper
146
+ ```
147
+
148
+ ### Key Components
149
+
150
+ - **OAuth Flow**: Authorization code flow with PKCE and automatic callback capture
151
+ - **Token Management**: Secure storage and retrieval helpers
152
+ - **Model Discovery**: API integration for model fetching
153
+ - **Plugin Registration**: Custom command handlers wired into Code Puppy
154
+
155
+ ## Notes
156
+
157
+ - The plugin assumes Anthropic continues to expose the shared `claude-cli` OAuth client
158
+ - Tokens are refreshed on subsequent API calls if the service returns refresh tokens
159
+ - Models are prefixed with `claude-code-` to avoid collisions with other Anthropic models
160
+
161
+ ## Contributing
162
+
163
+ When modifying this plugin:
164
+ 1. Maintain security best practices
165
+ 2. Test OAuth flow changes manually before shipping
166
+ 3. Update documentation for any configuration or UX changes
167
+ 4. Keep files under 600 lines; split into helpers when needed