code-puppy 0.0.169__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 (243) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +8 -8
  3. code_puppy/agents/agent_c_reviewer.py +155 -0
  4. code_puppy/agents/agent_code_puppy.py +9 -2
  5. code_puppy/agents/agent_code_reviewer.py +90 -0
  6. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  7. code_puppy/agents/agent_creator_agent.py +48 -9
  8. code_puppy/agents/agent_golang_reviewer.py +151 -0
  9. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  10. code_puppy/agents/agent_manager.py +146 -199
  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 +90 -0
  15. code_puppy/agents/agent_qa_expert.py +163 -0
  16. code_puppy/agents/agent_qa_kitten.py +208 -0
  17. code_puppy/agents/agent_security_auditor.py +181 -0
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  20. code_puppy/agents/base_agent.py +1713 -1
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/json_agent.py +12 -1
  23. code_puppy/agents/pack/__init__.py +34 -0
  24. code_puppy/agents/pack/bloodhound.py +304 -0
  25. code_puppy/agents/pack/husky.py +321 -0
  26. code_puppy/agents/pack/retriever.py +393 -0
  27. code_puppy/agents/pack/shepherd.py +348 -0
  28. code_puppy/agents/pack/terrier.py +287 -0
  29. code_puppy/agents/pack/watchdog.py +367 -0
  30. code_puppy/agents/prompt_reviewer.py +145 -0
  31. code_puppy/agents/subagent_stream_handler.py +276 -0
  32. code_puppy/api/__init__.py +13 -0
  33. code_puppy/api/app.py +169 -0
  34. code_puppy/api/main.py +21 -0
  35. code_puppy/api/pty_manager.py +446 -0
  36. code_puppy/api/routers/__init__.py +12 -0
  37. code_puppy/api/routers/agents.py +36 -0
  38. code_puppy/api/routers/commands.py +217 -0
  39. code_puppy/api/routers/config.py +74 -0
  40. code_puppy/api/routers/sessions.py +232 -0
  41. code_puppy/api/templates/terminal.html +361 -0
  42. code_puppy/api/websocket.py +154 -0
  43. code_puppy/callbacks.py +174 -4
  44. code_puppy/chatgpt_codex_client.py +283 -0
  45. code_puppy/claude_cache_client.py +586 -0
  46. code_puppy/cli_runner.py +916 -0
  47. code_puppy/command_line/add_model_menu.py +1079 -0
  48. code_puppy/command_line/agent_menu.py +395 -0
  49. code_puppy/command_line/attachments.py +395 -0
  50. code_puppy/command_line/autosave_menu.py +605 -0
  51. code_puppy/command_line/clipboard.py +527 -0
  52. code_puppy/command_line/colors_menu.py +520 -0
  53. code_puppy/command_line/command_handler.py +233 -627
  54. code_puppy/command_line/command_registry.py +150 -0
  55. code_puppy/command_line/config_commands.py +715 -0
  56. code_puppy/command_line/core_commands.py +792 -0
  57. code_puppy/command_line/diff_menu.py +863 -0
  58. code_puppy/command_line/load_context_completion.py +15 -22
  59. code_puppy/command_line/mcp/base.py +1 -4
  60. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  61. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  62. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  63. code_puppy/command_line/mcp/edit_command.py +148 -0
  64. code_puppy/command_line/mcp/handler.py +9 -4
  65. code_puppy/command_line/mcp/help_command.py +6 -5
  66. code_puppy/command_line/mcp/install_command.py +16 -27
  67. code_puppy/command_line/mcp/install_menu.py +685 -0
  68. code_puppy/command_line/mcp/list_command.py +3 -3
  69. code_puppy/command_line/mcp/logs_command.py +174 -65
  70. code_puppy/command_line/mcp/remove_command.py +2 -2
  71. code_puppy/command_line/mcp/restart_command.py +12 -4
  72. code_puppy/command_line/mcp/search_command.py +17 -11
  73. code_puppy/command_line/mcp/start_all_command.py +22 -13
  74. code_puppy/command_line/mcp/start_command.py +50 -31
  75. code_puppy/command_line/mcp/status_command.py +6 -7
  76. code_puppy/command_line/mcp/stop_all_command.py +11 -8
  77. code_puppy/command_line/mcp/stop_command.py +11 -10
  78. code_puppy/command_line/mcp/test_command.py +2 -2
  79. code_puppy/command_line/mcp/utils.py +1 -1
  80. code_puppy/command_line/mcp/wizard_utils.py +22 -18
  81. code_puppy/command_line/mcp_completion.py +174 -0
  82. code_puppy/command_line/model_picker_completion.py +89 -30
  83. code_puppy/command_line/model_settings_menu.py +884 -0
  84. code_puppy/command_line/motd.py +14 -8
  85. code_puppy/command_line/onboarding_slides.py +179 -0
  86. code_puppy/command_line/onboarding_wizard.py +340 -0
  87. code_puppy/command_line/pin_command_completion.py +329 -0
  88. code_puppy/command_line/prompt_toolkit_completion.py +626 -75
  89. code_puppy/command_line/session_commands.py +296 -0
  90. code_puppy/command_line/utils.py +54 -0
  91. code_puppy/config.py +1181 -51
  92. code_puppy/error_logging.py +118 -0
  93. code_puppy/gemini_code_assist.py +385 -0
  94. code_puppy/gemini_model.py +602 -0
  95. code_puppy/http_utils.py +220 -104
  96. code_puppy/keymap.py +128 -0
  97. code_puppy/main.py +5 -594
  98. code_puppy/{mcp → mcp_}/__init__.py +17 -0
  99. code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
  100. code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
  101. code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
  102. code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
  103. code_puppy/{mcp → mcp_}/dashboard.py +15 -6
  104. code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
  105. code_puppy/{mcp → mcp_}/managed_server.py +66 -39
  106. code_puppy/{mcp → mcp_}/manager.py +146 -52
  107. code_puppy/mcp_/mcp_logs.py +224 -0
  108. code_puppy/{mcp → mcp_}/registry.py +6 -6
  109. code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
  110. code_puppy/messaging/__init__.py +199 -2
  111. code_puppy/messaging/bus.py +610 -0
  112. code_puppy/messaging/commands.py +167 -0
  113. code_puppy/messaging/markdown_patches.py +57 -0
  114. code_puppy/messaging/message_queue.py +17 -48
  115. code_puppy/messaging/messages.py +500 -0
  116. code_puppy/messaging/queue_console.py +1 -24
  117. code_puppy/messaging/renderers.py +43 -146
  118. code_puppy/messaging/rich_renderer.py +1027 -0
  119. code_puppy/messaging/spinner/__init__.py +33 -5
  120. code_puppy/messaging/spinner/console_spinner.py +92 -52
  121. code_puppy/messaging/spinner/spinner_base.py +29 -0
  122. code_puppy/messaging/subagent_console.py +461 -0
  123. code_puppy/model_factory.py +686 -80
  124. code_puppy/model_utils.py +167 -0
  125. code_puppy/models.json +86 -104
  126. code_puppy/models_dev_api.json +1 -0
  127. code_puppy/models_dev_parser.py +592 -0
  128. code_puppy/plugins/__init__.py +164 -10
  129. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  130. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  131. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  132. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  133. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  134. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  135. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  136. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  137. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  138. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  139. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  140. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  141. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  142. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  143. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  144. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  145. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  146. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  147. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  148. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  149. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  150. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  151. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  152. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  153. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  154. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  155. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  156. code_puppy/plugins/example_custom_command/README.md +280 -0
  157. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  158. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  159. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  160. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  161. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  162. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  163. code_puppy/plugins/oauth_puppy_html.py +228 -0
  164. code_puppy/plugins/shell_safety/__init__.py +6 -0
  165. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  166. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  167. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  168. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  169. code_puppy/prompts/codex_system_prompt.md +310 -0
  170. code_puppy/pydantic_patches.py +131 -0
  171. code_puppy/reopenable_async_client.py +8 -8
  172. code_puppy/round_robin_model.py +10 -15
  173. code_puppy/session_storage.py +294 -0
  174. code_puppy/status_display.py +21 -4
  175. code_puppy/summarization_agent.py +52 -14
  176. code_puppy/terminal_utils.py +418 -0
  177. code_puppy/tools/__init__.py +139 -6
  178. code_puppy/tools/agent_tools.py +548 -49
  179. code_puppy/tools/browser/__init__.py +37 -0
  180. code_puppy/tools/browser/browser_control.py +289 -0
  181. code_puppy/tools/browser/browser_interactions.py +545 -0
  182. code_puppy/tools/browser/browser_locators.py +640 -0
  183. code_puppy/tools/browser/browser_manager.py +316 -0
  184. code_puppy/tools/browser/browser_navigation.py +251 -0
  185. code_puppy/tools/browser/browser_screenshot.py +179 -0
  186. code_puppy/tools/browser/browser_scripts.py +462 -0
  187. code_puppy/tools/browser/browser_workflows.py +221 -0
  188. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  189. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  190. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  191. code_puppy/tools/browser/terminal_tools.py +525 -0
  192. code_puppy/tools/command_runner.py +941 -153
  193. code_puppy/tools/common.py +1146 -6
  194. code_puppy/tools/display.py +84 -0
  195. code_puppy/tools/file_modifications.py +288 -89
  196. code_puppy/tools/file_operations.py +352 -266
  197. code_puppy/tools/subagent_context.py +158 -0
  198. code_puppy/uvx_detection.py +242 -0
  199. code_puppy/version_checker.py +30 -11
  200. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  201. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  202. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
  203. code_puppy-0.0.366.dist-info/RECORD +217 -0
  204. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  205. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
  206. code_puppy/agent.py +0 -231
  207. code_puppy/agents/agent_orchestrator.json +0 -26
  208. code_puppy/agents/runtime_manager.py +0 -272
  209. code_puppy/command_line/mcp/add_command.py +0 -183
  210. code_puppy/command_line/meta_command_handler.py +0 -153
  211. code_puppy/message_history_processor.py +0 -490
  212. code_puppy/messaging/spinner/textual_spinner.py +0 -101
  213. code_puppy/state_management.py +0 -200
  214. code_puppy/tui/__init__.py +0 -10
  215. code_puppy/tui/app.py +0 -986
  216. code_puppy/tui/components/__init__.py +0 -21
  217. code_puppy/tui/components/chat_view.py +0 -550
  218. code_puppy/tui/components/command_history_modal.py +0 -218
  219. code_puppy/tui/components/copy_button.py +0 -139
  220. code_puppy/tui/components/custom_widgets.py +0 -63
  221. code_puppy/tui/components/human_input_modal.py +0 -175
  222. code_puppy/tui/components/input_area.py +0 -167
  223. code_puppy/tui/components/sidebar.py +0 -309
  224. code_puppy/tui/components/status_bar.py +0 -182
  225. code_puppy/tui/messages.py +0 -27
  226. code_puppy/tui/models/__init__.py +0 -8
  227. code_puppy/tui/models/chat_message.py +0 -25
  228. code_puppy/tui/models/command_history.py +0 -89
  229. code_puppy/tui/models/enums.py +0 -24
  230. code_puppy/tui/screens/__init__.py +0 -15
  231. code_puppy/tui/screens/help.py +0 -130
  232. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  233. code_puppy/tui/screens/settings.py +0 -290
  234. code_puppy/tui/screens/tools.py +0 -74
  235. code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
  236. code_puppy-0.0.169.dist-info/RECORD +0 -112
  237. /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
  238. /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
  239. /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
  240. /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
  241. /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
  242. /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
  243. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,518 @@
1
+ """Utility helpers for the Claude Code OAuth plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import re
10
+ import secrets
11
+ import time
12
+ from dataclasses import dataclass
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+ from urllib.parse import urlencode
15
+
16
+ import requests
17
+
18
+ from .config import (
19
+ CLAUDE_CODE_OAUTH_CONFIG,
20
+ get_claude_models_path,
21
+ get_token_storage_path,
22
+ )
23
+
24
+ TOKEN_REFRESH_BUFFER_SECONDS = 60
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @dataclass
30
+ class OAuthContext:
31
+ """Runtime state for an in-progress OAuth flow."""
32
+
33
+ state: str
34
+ code_verifier: str
35
+ code_challenge: str
36
+ created_at: float
37
+ redirect_uri: Optional[str] = None
38
+
39
+
40
+ _oauth_context: Optional[OAuthContext] = None
41
+
42
+
43
+ def _urlsafe_b64encode(data: bytes) -> str:
44
+ return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
45
+
46
+
47
+ def _generate_code_verifier() -> str:
48
+ return _urlsafe_b64encode(secrets.token_bytes(64))
49
+
50
+
51
+ def _compute_code_challenge(code_verifier: str) -> str:
52
+ digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
53
+ return _urlsafe_b64encode(digest)
54
+
55
+
56
+ def prepare_oauth_context() -> OAuthContext:
57
+ """Create and cache a new OAuth PKCE context."""
58
+ global _oauth_context
59
+ state = secrets.token_urlsafe(32)
60
+ code_verifier = _generate_code_verifier()
61
+ code_challenge = _compute_code_challenge(code_verifier)
62
+ _oauth_context = OAuthContext(
63
+ state=state,
64
+ code_verifier=code_verifier,
65
+ code_challenge=code_challenge,
66
+ created_at=time.time(),
67
+ )
68
+ return _oauth_context
69
+
70
+
71
+ def get_oauth_context() -> Optional[OAuthContext]:
72
+ return _oauth_context
73
+
74
+
75
+ def clear_oauth_context() -> None:
76
+ global _oauth_context
77
+ _oauth_context = None
78
+
79
+
80
+ def assign_redirect_uri(context: OAuthContext, port: int) -> str:
81
+ """Assign redirect URI for the given OAuth context."""
82
+ if context is None:
83
+ raise RuntimeError("OAuth context cannot be None")
84
+
85
+ host = CLAUDE_CODE_OAUTH_CONFIG["redirect_host"].rstrip("/")
86
+ path = CLAUDE_CODE_OAUTH_CONFIG["redirect_path"].lstrip("/")
87
+ redirect_uri = f"{host}:{port}/{path}"
88
+ context.redirect_uri = redirect_uri
89
+ return redirect_uri
90
+
91
+
92
+ def build_authorization_url(context: OAuthContext) -> str:
93
+ """Return the Claude authorization URL with PKCE parameters."""
94
+ if not context.redirect_uri:
95
+ raise RuntimeError("Redirect URI has not been assigned for this OAuth context")
96
+
97
+ params = {
98
+ "response_type": "code",
99
+ "client_id": CLAUDE_CODE_OAUTH_CONFIG["client_id"],
100
+ "redirect_uri": context.redirect_uri,
101
+ "scope": CLAUDE_CODE_OAUTH_CONFIG["scope"],
102
+ "state": context.state,
103
+ "code": "true",
104
+ "code_challenge": context.code_challenge,
105
+ "code_challenge_method": "S256",
106
+ }
107
+ return f"{CLAUDE_CODE_OAUTH_CONFIG['auth_url']}?{urlencode(params)}"
108
+
109
+
110
+ def parse_authorization_code(raw_input: str) -> Tuple[str, Optional[str]]:
111
+ value = raw_input.strip()
112
+ if not value:
113
+ raise ValueError("Authorization code cannot be empty")
114
+
115
+ if "#" in value:
116
+ code, state = value.split("#", 1)
117
+ return code.strip(), state.strip() or None
118
+
119
+ parts = value.split()
120
+ if len(parts) == 2:
121
+ return parts[0].strip(), parts[1].strip() or None
122
+
123
+ return value, None
124
+
125
+
126
+ def load_stored_tokens() -> Optional[Dict[str, Any]]:
127
+ try:
128
+ token_path = get_token_storage_path()
129
+ if token_path.exists():
130
+ with open(token_path, "r", encoding="utf-8") as handle:
131
+ return json.load(handle)
132
+ except Exception as exc: # pragma: no cover - defensive logging
133
+ logger.error("Failed to load tokens: %s", exc)
134
+ return None
135
+
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
+
255
+ def save_tokens(tokens: Dict[str, Any]) -> bool:
256
+ try:
257
+ token_path = get_token_storage_path()
258
+ with open(token_path, "w", encoding="utf-8") as handle:
259
+ json.dump(tokens, handle, indent=2)
260
+ token_path.chmod(0o600)
261
+ return True
262
+ except Exception as exc: # pragma: no cover - defensive logging
263
+ logger.error("Failed to save tokens: %s", exc)
264
+ return False
265
+
266
+
267
+ def load_claude_models() -> Dict[str, Any]:
268
+ try:
269
+ models_path = get_claude_models_path()
270
+ if models_path.exists():
271
+ with open(models_path, "r", encoding="utf-8") as handle:
272
+ return json.load(handle)
273
+ except Exception as exc: # pragma: no cover - defensive logging
274
+ logger.error("Failed to load Claude models: %s", exc)
275
+ return {}
276
+
277
+
278
+ def load_claude_models_filtered() -> Dict[str, Any]:
279
+ """Load Claude models and filter to only the latest versions.
280
+
281
+ This loads the stored models and applies the same filtering logic
282
+ used during saving to ensure only the latest haiku, sonnet, and opus
283
+ models are returned.
284
+ """
285
+ try:
286
+ all_models = load_claude_models()
287
+ if not all_models:
288
+ return {}
289
+
290
+ # Extract model names from the configuration
291
+ model_names = []
292
+ for name, config in all_models.items():
293
+ if config.get("oauth_source") == "claude-code-plugin":
294
+ model_names.append(config.get("name", ""))
295
+ else:
296
+ # For non-OAuth models, use the full key
297
+ model_names.append(name)
298
+
299
+ # Filter to only latest models
300
+ latest_names = set(filter_latest_claude_models(model_names))
301
+
302
+ # Return only the filtered models
303
+ filtered_models = {}
304
+ for name, config in all_models.items():
305
+ model_name = config.get("name", name)
306
+ if model_name in latest_names:
307
+ filtered_models[name] = config
308
+
309
+ logger.info(
310
+ "Loaded %d models, filtered to %d latest models",
311
+ len(all_models),
312
+ len(filtered_models),
313
+ )
314
+ return filtered_models
315
+
316
+ except Exception as exc: # pragma: no cover - defensive logging
317
+ logger.error("Failed to load and filter Claude models: %s", exc)
318
+ return {}
319
+
320
+
321
+ def save_claude_models(models: Dict[str, Any]) -> bool:
322
+ try:
323
+ models_path = get_claude_models_path()
324
+ with open(models_path, "w", encoding="utf-8") as handle:
325
+ json.dump(models, handle, indent=2)
326
+ return True
327
+ except Exception as exc: # pragma: no cover - defensive logging
328
+ logger.error("Failed to save Claude models: %s", exc)
329
+ return False
330
+
331
+
332
+ def exchange_code_for_tokens(
333
+ auth_code: str, context: OAuthContext
334
+ ) -> Optional[Dict[str, Any]]:
335
+ if not context.redirect_uri:
336
+ raise RuntimeError("Redirect URI missing from OAuth context")
337
+
338
+ payload = {
339
+ "grant_type": "authorization_code",
340
+ "client_id": CLAUDE_CODE_OAUTH_CONFIG["client_id"],
341
+ "code": auth_code,
342
+ "state": context.state,
343
+ "code_verifier": context.code_verifier,
344
+ "redirect_uri": context.redirect_uri,
345
+ }
346
+
347
+ headers = {
348
+ "Content-Type": "application/json",
349
+ "Accept": "application/json",
350
+ "anthropic-beta": "oauth-2025-04-20",
351
+ }
352
+
353
+ logger.info("Exchanging code for tokens: %s", CLAUDE_CODE_OAUTH_CONFIG["token_url"])
354
+ logger.debug("Payload keys: %s", list(payload.keys()))
355
+ logger.debug("Headers: %s", headers)
356
+ try:
357
+ response = requests.post(
358
+ CLAUDE_CODE_OAUTH_CONFIG["token_url"],
359
+ json=payload,
360
+ headers=headers,
361
+ timeout=30,
362
+ )
363
+ logger.info("Token exchange response: %s", response.status_code)
364
+ logger.debug("Response body: %s", response.text)
365
+ if response.status_code == 200:
366
+ token_data = response.json()
367
+ token_data["expires_at"] = _calculate_expires_at(
368
+ token_data.get("expires_in")
369
+ )
370
+ return token_data
371
+ logger.error(
372
+ "Token exchange failed: %s - %s",
373
+ response.status_code,
374
+ response.text,
375
+ )
376
+ except Exception as exc: # pragma: no cover - defensive logging
377
+ logger.error("Token exchange error: %s", exc)
378
+ return None
379
+
380
+
381
+ def filter_latest_claude_models(models: List[str]) -> List[str]:
382
+ """Filter models to keep only the latest haiku, sonnet, and opus.
383
+
384
+ Parses model names in the format claude-{family}-{major}-{minor}-{date}
385
+ and returns only the latest version of each family (haiku, sonnet, opus).
386
+ """
387
+ # Dictionary to store the latest model for each family
388
+ # family -> (model_name, major, minor, date)
389
+ latest_models: Dict[str, Tuple[str, int, int, int]] = {}
390
+
391
+ for model_name in models:
392
+ # Match pattern: claude-{family}-{major}-{minor}-{date}
393
+ # Examples: claude-haiku-3-5-20241022, claude-sonnet-4-5-20250929
394
+ match = re.match(r"claude-(haiku|sonnet|opus)-(\d+)-(\d+)-(\d+)", model_name)
395
+ if not match:
396
+ # Also try pattern with dots: claude-{family}-{major}.{minor}-{date}
397
+ match = re.match(
398
+ r"claude-(haiku|sonnet|opus)-(\d+)\.(\d+)-(\d+)", model_name
399
+ )
400
+
401
+ if not match:
402
+ continue
403
+
404
+ family = match.group(1)
405
+ major = int(match.group(2))
406
+ minor = int(match.group(3))
407
+ date = int(match.group(4))
408
+
409
+ if family not in latest_models:
410
+ latest_models[family] = (model_name, major, minor, date)
411
+ else:
412
+ # Compare versions: first by major, then minor, then date
413
+ _, cur_major, cur_minor, cur_date = latest_models[family]
414
+ if (major, minor, date) > (cur_major, cur_minor, cur_date):
415
+ latest_models[family] = (model_name, major, minor, date)
416
+
417
+ # Return only the model names
418
+ filtered = [model_data[0] for model_data in latest_models.values()]
419
+ logger.info(
420
+ "Filtered %d models to %d latest models: %s",
421
+ len(models),
422
+ len(filtered),
423
+ filtered,
424
+ )
425
+ return filtered
426
+
427
+
428
+ def fetch_claude_code_models(access_token: str) -> Optional[List[str]]:
429
+ try:
430
+ api_url = f"{CLAUDE_CODE_OAUTH_CONFIG['api_base_url']}/v1/models"
431
+ headers = {
432
+ "Authorization": f"Bearer {access_token}",
433
+ "Content-Type": "application/json",
434
+ "anthropic-beta": "oauth-2025-04-20",
435
+ "anthropic-version": CLAUDE_CODE_OAUTH_CONFIG.get(
436
+ "anthropic_version", "2023-06-01"
437
+ ),
438
+ }
439
+ response = requests.get(api_url, headers=headers, timeout=30)
440
+ if response.status_code == 200:
441
+ data = response.json()
442
+ if isinstance(data.get("data"), list):
443
+ models: List[str] = []
444
+ for model in data["data"]:
445
+ name = model.get("id") or model.get("name")
446
+ if name:
447
+ models.append(name)
448
+ return models
449
+ else:
450
+ logger.error(
451
+ "Failed to fetch models: %s - %s",
452
+ response.status_code,
453
+ response.text,
454
+ )
455
+ except Exception as exc: # pragma: no cover - defensive logging
456
+ logger.error("Error fetching Claude Code models: %s", exc)
457
+ return None
458
+
459
+
460
+ def add_models_to_extra_config(models: List[str]) -> bool:
461
+ try:
462
+ # Filter to only latest haiku, sonnet, and opus models
463
+ filtered_models = filter_latest_claude_models(models)
464
+
465
+ # Start fresh - overwrite the file on every auth instead of loading existing
466
+ claude_models = {}
467
+ added = 0
468
+ access_token = get_valid_access_token() or ""
469
+
470
+ for model_name in filtered_models:
471
+ prefixed = f"{CLAUDE_CODE_OAUTH_CONFIG['prefix']}{model_name}"
472
+ claude_models[prefixed] = {
473
+ "type": "claude_code",
474
+ "name": model_name,
475
+ "custom_endpoint": {
476
+ "url": CLAUDE_CODE_OAUTH_CONFIG["api_base_url"],
477
+ "api_key": access_token,
478
+ "headers": {
479
+ "anthropic-beta": "oauth-2025-04-20,interleaved-thinking-2025-05-14",
480
+ "x-app": "cli",
481
+ "User-Agent": "claude-cli/2.0.61 (external, cli)",
482
+ },
483
+ },
484
+ "context_length": CLAUDE_CODE_OAUTH_CONFIG["default_context_length"],
485
+ "oauth_source": "claude-code-plugin",
486
+ "supported_settings": [
487
+ "temperature",
488
+ "extended_thinking",
489
+ "budget_tokens",
490
+ "interleaved_thinking",
491
+ ],
492
+ }
493
+ added += 1
494
+ if save_claude_models(claude_models):
495
+ logger.info("Added %s Claude Code models", added)
496
+ return True
497
+ except Exception as exc: # pragma: no cover - defensive logging
498
+ logger.error("Error adding models to config: %s", exc)
499
+ return False
500
+
501
+
502
+ def remove_claude_code_models() -> int:
503
+ try:
504
+ claude_models = load_claude_models()
505
+ to_remove = [
506
+ name
507
+ for name, config in claude_models.items()
508
+ if config.get("oauth_source") == "claude-code-plugin"
509
+ ]
510
+ if not to_remove:
511
+ return 0
512
+ for model_name in to_remove:
513
+ claude_models.pop(model_name, None)
514
+ if save_claude_models(claude_models):
515
+ return len(to_remove)
516
+ except Exception as exc: # pragma: no cover - defensive logging
517
+ logger.error("Error removing Claude Code models: %s", exc)
518
+ return 0
File without changes
@@ -0,0 +1,169 @@
1
+ from pathlib import Path
2
+ from typing import Any, Dict, List, Optional, Tuple
3
+
4
+ from code_puppy.callbacks import register_callback
5
+ from code_puppy.messaging import emit_error, emit_info
6
+
7
+ # Global cache for loaded commands
8
+ _custom_commands: Dict[str, str] = {}
9
+ _command_descriptions: Dict[str, str] = {}
10
+
11
+ # Directories to scan for commands
12
+ _COMMAND_DIRECTORIES = [".claude/commands", ".github/prompts", ".agents/commands"]
13
+
14
+
15
+ class MarkdownCommandResult:
16
+ """Special marker for markdown command results that should be processed as input."""
17
+
18
+ def __init__(self, content: str):
19
+ self.content = content
20
+
21
+ def __str__(self) -> str:
22
+ return self.content
23
+
24
+ def __repr__(self) -> str:
25
+ return f"MarkdownCommandResult({len(self.content)} chars)"
26
+
27
+
28
+ def _load_markdown_commands() -> None:
29
+ """Load markdown command files from the configured directories.
30
+
31
+ Scans for *.md files in the configured directories and loads them
32
+ as custom commands. Handles duplicates by appending numeric suffixes.
33
+ """
34
+ global _custom_commands, _command_descriptions
35
+
36
+ _custom_commands.clear()
37
+ _command_descriptions.clear()
38
+
39
+ loaded_files = []
40
+
41
+ for directory in _COMMAND_DIRECTORIES:
42
+ dir_path = Path(directory).expanduser()
43
+ if not dir_path.exists():
44
+ continue
45
+
46
+ # Look for markdown files
47
+ pattern = "*.md" if directory != ".github/prompts" else "*.prompt.md"
48
+ for md_file in dir_path.glob(pattern):
49
+ loaded_files.append(md_file)
50
+
51
+ # Sort for consistent ordering
52
+ loaded_files.sort()
53
+
54
+ for md_file in loaded_files:
55
+ try:
56
+ # Extract command name from filename
57
+ if md_file.name.endswith(".prompt.md"):
58
+ base_name = md_file.name[: -len(".prompt.md")]
59
+ else:
60
+ base_name = md_file.stem
61
+
62
+ # Generate unique command name
63
+ command_name = _generate_unique_command_name(base_name)
64
+
65
+ # Read file content
66
+ content = md_file.read_text(encoding="utf-8").strip()
67
+ if not content:
68
+ continue
69
+
70
+ # Extract first line as description (or use filename)
71
+ lines = content.split("\n")
72
+ description = base_name.replace("_", " ").replace("-", " ").title()
73
+
74
+ # Try to get description from first non-empty line that's not a heading
75
+ for line in lines:
76
+ line = line.strip()
77
+ if line and not line.startswith("#"):
78
+ # Truncate long descriptions
79
+ description = line[:50] + ("..." if len(line) > 50 else "")
80
+ break
81
+
82
+ _custom_commands[command_name] = content
83
+ _command_descriptions[command_name] = description
84
+
85
+ except Exception as e:
86
+ emit_error(f"Failed to load command from {md_file}: {e}")
87
+
88
+
89
+ def _generate_unique_command_name(base_name: str) -> str:
90
+ """Generate a unique command name, handling duplicates.
91
+
92
+ Args:
93
+ base_name: The base command name from filename
94
+
95
+ Returns:
96
+ Unique command name (may have numeric suffix)
97
+ """
98
+ if base_name not in _custom_commands:
99
+ return base_name
100
+
101
+ # Try numeric suffixes
102
+ counter = 2
103
+ while True:
104
+ candidate = f"{base_name}{counter}"
105
+ if candidate not in _custom_commands:
106
+ return candidate
107
+ counter += 1
108
+
109
+
110
+ def _custom_help() -> List[Tuple[str, str]]:
111
+ """Return help entries for loaded markdown commands."""
112
+ # Reload commands to pick up any changes
113
+ _load_markdown_commands()
114
+
115
+ help_entries = []
116
+ for name, description in sorted(_command_descriptions.items()):
117
+ help_entries.append((name, f"Execute markdown command: {description}"))
118
+
119
+ return help_entries
120
+
121
+
122
+ def _handle_custom_command(command: str, name: str) -> Optional[Any]:
123
+ """Handle a markdown-based custom command.
124
+
125
+ Args:
126
+ command: The full command string
127
+ name: The command name without leading slash
128
+
129
+ Returns:
130
+ MarkdownCommandResult with content to be processed as input,
131
+ or None if not found
132
+ """
133
+ if not name:
134
+ return None
135
+
136
+ # Ensure commands are loaded
137
+ if not _custom_commands:
138
+ _load_markdown_commands()
139
+
140
+ # Look up the command
141
+ content = _custom_commands.get(name)
142
+ if content is None:
143
+ return None
144
+
145
+ # Extract any additional arguments from the command
146
+ parts = command.split(maxsplit=1)
147
+ args = parts[1] if len(parts) > 1 else ""
148
+
149
+ # If there are arguments, append them to the prompt
150
+ if args:
151
+ prompt = f"{content}\n\nAdditional context: {args}"
152
+ else:
153
+ prompt = content
154
+
155
+ # Emit info message and return the special marker
156
+ emit_info(f"📝 Executing markdown command: {name}")
157
+ return MarkdownCommandResult(prompt)
158
+
159
+
160
+ # Register callbacks
161
+ register_callback("custom_command_help", _custom_help)
162
+ register_callback("custom_command", _handle_custom_command)
163
+
164
+ # Make the result class available for the command handler
165
+ # Import this in command_handler.py to check for this type
166
+ __all__ = ["MarkdownCommandResult"]
167
+
168
+ # Load commands at import time
169
+ _load_markdown_commands()