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,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)
@@ -0,0 +1,8 @@
1
+ """ChatGPT OAuth plugin package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from . import register_callbacks # noqa: F401
6
+ from .oauth_flow import run_oauth_flow
7
+
8
+ __all__ = ["run_oauth_flow"]
@@ -0,0 +1,52 @@
1
+ from pathlib import Path
2
+ from typing import Any, Dict
3
+
4
+ from code_puppy import config
5
+
6
+ # ChatGPT OAuth configuration based on OpenAI's Codex CLI flow
7
+ CHATGPT_OAUTH_CONFIG: Dict[str, Any] = {
8
+ # OAuth endpoints from OpenAI auth service
9
+ "issuer": "https://auth.openai.com",
10
+ "auth_url": "https://auth.openai.com/oauth/authorize",
11
+ "token_url": "https://auth.openai.com/oauth/token",
12
+ # API endpoints - Codex uses chatgpt.com backend, not api.openai.com
13
+ "api_base_url": "https://chatgpt.com/backend-api/codex",
14
+ # OAuth client configuration for Code Puppy
15
+ "client_id": "app_EMoamEEZ73f0CkXaXp7hrann",
16
+ "scope": "openid profile email offline_access",
17
+ # Callback handling (we host a localhost callback to capture the redirect)
18
+ "redirect_host": "http://localhost",
19
+ "redirect_path": "auth/callback",
20
+ "required_port": 1455,
21
+ "callback_timeout": 120,
22
+ # Local configuration (uses XDG_DATA_HOME)
23
+ "token_storage": None, # Set dynamically in get_token_storage_path()
24
+ # Model configuration
25
+ "prefix": "chatgpt-",
26
+ "default_context_length": 272000,
27
+ "api_key_env_var": "CHATGPT_OAUTH_API_KEY",
28
+ # Codex CLI version info (for User-Agent header)
29
+ "client_version": "0.72.0",
30
+ "originator": "codex_cli_rs",
31
+ }
32
+
33
+
34
+ def get_token_storage_path() -> Path:
35
+ """Get the path for storing OAuth tokens (uses XDG_DATA_HOME)."""
36
+ data_dir = Path(config.DATA_DIR)
37
+ data_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
38
+ return data_dir / "chatgpt_oauth.json"
39
+
40
+
41
+ def get_config_dir() -> Path:
42
+ """Get the Code Puppy configuration directory (uses XDG_CONFIG_HOME)."""
43
+ config_dir = Path(config.CONFIG_DIR)
44
+ config_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
45
+ return config_dir
46
+
47
+
48
+ def get_chatgpt_models_path() -> Path:
49
+ """Get the path to the dedicated chatgpt_models.json file (uses XDG_DATA_HOME)."""
50
+ data_dir = Path(config.DATA_DIR)
51
+ data_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
52
+ return data_dir / "chatgpt_models.json"
@@ -0,0 +1,328 @@
1
+ """ChatGPT OAuth flow closely matching the ChatMock implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import threading
7
+ import time
8
+ import urllib.parse
9
+ from dataclasses import dataclass
10
+ from http.server import BaseHTTPRequestHandler, HTTPServer
11
+ from typing import Any, Optional, Tuple
12
+
13
+ import requests
14
+
15
+ from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning
16
+
17
+ from ..oauth_puppy_html import oauth_failure_html, oauth_success_html
18
+ from .config import CHATGPT_OAUTH_CONFIG
19
+ from .utils import (
20
+ add_models_to_extra_config,
21
+ assign_redirect_uri,
22
+ load_stored_tokens,
23
+ parse_jwt_claims,
24
+ prepare_oauth_context,
25
+ save_tokens,
26
+ )
27
+
28
+ REQUIRED_PORT = CHATGPT_OAUTH_CONFIG["required_port"]
29
+ URL_BASE = f"http://localhost:{REQUIRED_PORT}"
30
+
31
+
32
+ @dataclass
33
+ class TokenData:
34
+ id_token: str
35
+ access_token: str
36
+ refresh_token: str
37
+ account_id: str
38
+
39
+
40
+ @dataclass
41
+ class AuthBundle:
42
+ api_key: Optional[str]
43
+ token_data: TokenData
44
+ last_refresh: str
45
+
46
+
47
+ class _OAuthServer(HTTPServer):
48
+ def __init__(
49
+ self,
50
+ *,
51
+ client_id: str,
52
+ verbose: bool = False,
53
+ ) -> None:
54
+ super().__init__(
55
+ ("localhost", REQUIRED_PORT), _CallbackHandler, bind_and_activate=True
56
+ )
57
+ self.exit_code = 1
58
+ self.verbose = verbose
59
+ self.client_id = client_id
60
+ self.issuer = CHATGPT_OAUTH_CONFIG["issuer"]
61
+ self.token_endpoint = CHATGPT_OAUTH_CONFIG["token_url"]
62
+
63
+ # Create fresh OAuth context for this server instance
64
+ context = prepare_oauth_context()
65
+ self.redirect_uri = assign_redirect_uri(context, REQUIRED_PORT)
66
+ self.context = context
67
+
68
+ def auth_url(self) -> str:
69
+ params = {
70
+ "response_type": "code",
71
+ "client_id": self.client_id,
72
+ "redirect_uri": self.redirect_uri,
73
+ "scope": CHATGPT_OAUTH_CONFIG["scope"],
74
+ "code_challenge": self.context.code_challenge,
75
+ "code_challenge_method": "S256",
76
+ "id_token_add_organizations": "true",
77
+ "codex_cli_simplified_flow": "true",
78
+ "state": self.context.state,
79
+ }
80
+ return f"{self.issuer}/oauth/authorize?" + urllib.parse.urlencode(params)
81
+
82
+ def exchange_code(self, code: str) -> Tuple[AuthBundle, str]:
83
+ data = {
84
+ "grant_type": "authorization_code",
85
+ "code": code,
86
+ "redirect_uri": self.redirect_uri,
87
+ "client_id": self.client_id,
88
+ "code_verifier": self.context.code_verifier,
89
+ }
90
+
91
+ response = requests.post(
92
+ self.token_endpoint,
93
+ data=data,
94
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
95
+ timeout=30,
96
+ )
97
+ response.raise_for_status()
98
+ payload = response.json()
99
+
100
+ id_token = payload.get("id_token", "")
101
+ access_token = payload.get("access_token", "")
102
+ refresh_token = payload.get("refresh_token", "")
103
+
104
+ id_token_claims = parse_jwt_claims(id_token) or {}
105
+ access_token_claims = parse_jwt_claims(access_token) or {}
106
+
107
+ auth_claims = id_token_claims.get("https://api.openai.com/auth") or {}
108
+ chatgpt_account_id = auth_claims.get("chatgpt_account_id", "")
109
+ # Extract org_id from nested auth structure like ChatMock
110
+ organizations = auth_claims.get("organizations", [])
111
+ org_id = None
112
+ if organizations:
113
+ default_org = next(
114
+ (org for org in organizations if org.get("is_default")),
115
+ organizations[0],
116
+ )
117
+ org_id = default_org.get("id")
118
+ # Fallback to top-level org_id if still not found
119
+ if not org_id:
120
+ org_id = id_token_claims.get("organization_id")
121
+
122
+ token_data = TokenData(
123
+ id_token=id_token,
124
+ access_token=access_token,
125
+ refresh_token=refresh_token,
126
+ account_id=chatgpt_account_id,
127
+ )
128
+
129
+ # Instead of exchanging for an API key, just use the access_token directly
130
+ # This matches how ChatMock works - no token exchange, just OAuth tokens
131
+ api_key = token_data.access_token
132
+
133
+ last_refresh = (
134
+ datetime.datetime.now(datetime.timezone.utc)
135
+ .isoformat()
136
+ .replace("+00:00", "Z")
137
+ )
138
+ bundle = AuthBundle(
139
+ api_key=api_key, token_data=token_data, last_refresh=last_refresh
140
+ )
141
+
142
+ # Build success URL with all the token info
143
+ success_query = {
144
+ "id_token": token_data.id_token,
145
+ "access_token": token_data.access_token,
146
+ "refresh_token": token_data.refresh_token,
147
+ "org_id": org_id or "",
148
+ "plan_type": access_token_claims.get("chatgpt_plan_type"),
149
+ "platform_url": "https://platform.openai.com",
150
+ }
151
+ success_url = f"{URL_BASE}/success?{urllib.parse.urlencode(success_query)}"
152
+ return bundle, success_url
153
+
154
+
155
+ class _CallbackHandler(BaseHTTPRequestHandler):
156
+ server: "_OAuthServer"
157
+
158
+ def do_GET(self) -> None: # noqa: N802
159
+ path = urllib.parse.urlparse(self.path).path
160
+ if path == "/success":
161
+ success_html = oauth_success_html(
162
+ "ChatGPT",
163
+ "You can now close this window and return to Code Puppy.",
164
+ )
165
+ self._send_html(success_html)
166
+ self._shutdown_after_delay(2.0)
167
+ return
168
+
169
+ if path != "/auth/callback":
170
+ self._send_failure(404, "Callback endpoint not found for the puppy parade.")
171
+ self._shutdown()
172
+ return
173
+
174
+ query = urllib.parse.urlparse(self.path).query
175
+ params = urllib.parse.parse_qs(query)
176
+
177
+ code = params.get("code", [None])[0]
178
+ if not code:
179
+ self._send_failure(400, "Missing auth code — the token treat rolled away.")
180
+ self._shutdown()
181
+ return
182
+
183
+ try:
184
+ auth_bundle, success_url = self.server.exchange_code(code)
185
+ except Exception as exc: # noqa: BLE001
186
+ self._send_failure(500, f"Token exchange failed: {exc}")
187
+ self._shutdown()
188
+ return
189
+
190
+ tokens = {
191
+ "id_token": auth_bundle.token_data.id_token,
192
+ "access_token": auth_bundle.token_data.access_token,
193
+ "refresh_token": auth_bundle.token_data.refresh_token,
194
+ "account_id": auth_bundle.token_data.account_id,
195
+ "last_refresh": auth_bundle.last_refresh,
196
+ }
197
+ if auth_bundle.api_key:
198
+ tokens["api_key"] = auth_bundle.api_key
199
+
200
+ if save_tokens(tokens):
201
+ self.server.exit_code = 0
202
+ # Redirect to the success URL returned by exchange_code
203
+ self._send_redirect(success_url)
204
+ else:
205
+ self._send_failure(
206
+ 500, "Unable to persist auth file — a puppy probably chewed it."
207
+ )
208
+ self._shutdown()
209
+ self._shutdown_after_delay(2.0)
210
+
211
+ def do_POST(self) -> None: # noqa: N802
212
+ self._send_failure(
213
+ 404, "POST not supported — the pups only fetch GET requests."
214
+ )
215
+ self._shutdown()
216
+
217
+ def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
218
+ if getattr(self.server, "verbose", False):
219
+ super().log_message(fmt, *args)
220
+
221
+ def _send_redirect(self, url: str) -> None:
222
+ self.send_response(302)
223
+ self.send_header("Location", url)
224
+ self.end_headers()
225
+
226
+ def _send_html(self, body: str, status: int = 200) -> None:
227
+ encoded = body.encode("utf-8")
228
+ self.send_response(status)
229
+ self.send_header("Content-Type", "text/html; charset=utf-8")
230
+ self.send_header("Content-Length", str(len(encoded)))
231
+ self.end_headers()
232
+ self.wfile.write(encoded)
233
+
234
+ def _send_failure(self, status: int, reason: str) -> None:
235
+ failure_html = oauth_failure_html("ChatGPT", reason)
236
+ self._send_html(failure_html, status)
237
+
238
+ def _shutdown(self) -> None:
239
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
240
+
241
+ def _shutdown_after_delay(self, seconds: float = 2.0) -> None:
242
+ def _later() -> None:
243
+ try:
244
+ time.sleep(seconds)
245
+ finally:
246
+ self._shutdown()
247
+
248
+ threading.Thread(target=_later, daemon=True).start()
249
+
250
+
251
+ def run_oauth_flow() -> None:
252
+ existing_tokens = load_stored_tokens()
253
+ if existing_tokens and existing_tokens.get("access_token"):
254
+ emit_warning("Existing ChatGPT tokens will be overwritten.")
255
+
256
+ try:
257
+ server = _OAuthServer(client_id=CHATGPT_OAUTH_CONFIG["client_id"])
258
+ except OSError as exc:
259
+ emit_error(f"Could not start OAuth server on port {REQUIRED_PORT}: {exc}")
260
+ emit_info(f"Use `lsof -ti:{REQUIRED_PORT} | xargs kill` to free the port.")
261
+ return
262
+
263
+ auth_url = server.auth_url()
264
+ emit_info(f"Open this URL in your browser: {auth_url}")
265
+
266
+ server_thread = threading.Thread(target=server.serve_forever, daemon=True)
267
+ server_thread.start()
268
+
269
+ webbrowser_opened = False
270
+ try:
271
+ import webbrowser
272
+
273
+ from code_puppy.tools.common import should_suppress_browser
274
+
275
+ if should_suppress_browser():
276
+ emit_info(f"[HEADLESS MODE] Would normally open: {auth_url}")
277
+ else:
278
+ webbrowser_opened = webbrowser.open(auth_url)
279
+ except Exception as exc: # noqa: BLE001
280
+ emit_warning(f"Could not open browser automatically: {exc}")
281
+
282
+ if not webbrowser_opened and not should_suppress_browser():
283
+ emit_warning("Please open the URL manually if the browser did not open.")
284
+
285
+ emit_info("Waiting for authentication callback…")
286
+
287
+ elapsed = 0.0
288
+ timeout = CHATGPT_OAUTH_CONFIG["callback_timeout"]
289
+ interval = 0.25
290
+ while elapsed < timeout:
291
+ time.sleep(interval)
292
+ elapsed += interval
293
+ if server.exit_code == 0:
294
+ break
295
+
296
+ server.shutdown()
297
+ server_thread.join(timeout=5)
298
+
299
+ if server.exit_code != 0:
300
+ emit_error("Authentication failed or timed out.")
301
+ return
302
+
303
+ tokens = load_stored_tokens()
304
+ if not tokens:
305
+ emit_error("Tokens saved during OAuth flow could not be loaded.")
306
+ return
307
+
308
+ api_key = tokens.get("api_key")
309
+ if api_key:
310
+ emit_success("Successfully obtained OAuth access token for API access.")
311
+ emit_info(
312
+ f"Access token saved and available via {CHATGPT_OAUTH_CONFIG['api_key_env_var']}"
313
+ )
314
+ else:
315
+ emit_warning(
316
+ "No API key obtained. You may need to configure projects at platform.openai.com."
317
+ )
318
+
319
+ if api_key:
320
+ emit_info("Registering ChatGPT Codex models…")
321
+ from .utils import DEFAULT_CODEX_MODELS
322
+
323
+ models = DEFAULT_CODEX_MODELS
324
+ if models:
325
+ if add_models_to_extra_config(models):
326
+ emit_success(
327
+ "ChatGPT models registered. Use the `chatgpt-` prefix in /model."
328
+ )
@@ -0,0 +1,94 @@
1
+ """ChatGPT OAuth plugin callbacks aligned with ChatMock flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import List, Optional, Tuple
7
+
8
+ from code_puppy.callbacks import register_callback
9
+ from code_puppy.config import set_model_name
10
+ from code_puppy.messaging import emit_info, emit_success, emit_warning
11
+
12
+ from .config import CHATGPT_OAUTH_CONFIG, get_token_storage_path
13
+ from .oauth_flow import run_oauth_flow
14
+ from .utils import load_chatgpt_models, load_stored_tokens, remove_chatgpt_models
15
+
16
+
17
+ def _custom_help() -> List[Tuple[str, str]]:
18
+ return [
19
+ (
20
+ "chatgpt-auth",
21
+ "Authenticate with ChatGPT via OAuth and import available models",
22
+ ),
23
+ (
24
+ "chatgpt-status",
25
+ "Check ChatGPT OAuth authentication status and configured models",
26
+ ),
27
+ ("chatgpt-logout", "Remove ChatGPT OAuth tokens and imported models"),
28
+ ]
29
+
30
+
31
+ def _handle_chatgpt_status() -> None:
32
+ tokens = load_stored_tokens()
33
+ if tokens and tokens.get("access_token"):
34
+ emit_success("🔐 ChatGPT OAuth: Authenticated")
35
+
36
+ api_key = tokens.get("api_key")
37
+ if api_key:
38
+ os.environ[CHATGPT_OAUTH_CONFIG["api_key_env_var"]] = api_key
39
+ emit_info("✅ OAuth access token available for API requests")
40
+ else:
41
+ emit_warning("⚠️ No access token obtained. Authentication may have failed.")
42
+
43
+ chatgpt_models = [
44
+ name
45
+ for name, cfg in load_chatgpt_models().items()
46
+ if cfg.get("oauth_source") == "chatgpt-oauth-plugin"
47
+ ]
48
+ if chatgpt_models:
49
+ emit_info(f"🎯 Configured ChatGPT models: {', '.join(chatgpt_models)}")
50
+ else:
51
+ emit_warning("⚠️ No ChatGPT models configured yet.")
52
+ else:
53
+ emit_warning("🔓 ChatGPT OAuth: Not authenticated")
54
+ emit_info("🌐 Run /chatgpt-auth to launch the browser sign-in flow.")
55
+
56
+
57
+ def _handle_chatgpt_logout() -> None:
58
+ token_path = get_token_storage_path()
59
+ if token_path.exists():
60
+ token_path.unlink()
61
+ emit_info("Removed ChatGPT OAuth tokens")
62
+
63
+ if CHATGPT_OAUTH_CONFIG["api_key_env_var"] in os.environ:
64
+ del os.environ[CHATGPT_OAUTH_CONFIG["api_key_env_var"]]
65
+
66
+ removed = remove_chatgpt_models()
67
+ if removed:
68
+ emit_info(f"Removed {removed} ChatGPT models from configuration")
69
+
70
+ emit_success("ChatGPT logout complete")
71
+
72
+
73
+ def _handle_custom_command(command: str, name: str) -> Optional[bool]:
74
+ if not name:
75
+ return None
76
+
77
+ if name == "chatgpt-auth":
78
+ run_oauth_flow()
79
+ set_model_name("chatgpt-gpt-5.2-codex")
80
+ return True
81
+
82
+ if name == "chatgpt-status":
83
+ _handle_chatgpt_status()
84
+ return True
85
+
86
+ if name == "chatgpt-logout":
87
+ _handle_chatgpt_logout()
88
+ return True
89
+
90
+ return None
91
+
92
+
93
+ register_callback("custom_command_help", _custom_help)
94
+ register_callback("custom_command", _handle_custom_command)