gac 1.13.0__py3-none-any.whl → 3.6.0__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.
@@ -0,0 +1,397 @@
1
+ """Claude Code OAuth authentication utilities.
2
+
3
+ Implements PKCE OAuth flow for Claude Code subscriptions.
4
+ """
5
+
6
+ import base64
7
+ import hashlib
8
+ import logging
9
+ import secrets
10
+ import threading
11
+ import time
12
+ import webbrowser
13
+ from dataclasses import dataclass
14
+ from http.server import BaseHTTPRequestHandler, HTTPServer
15
+ from pathlib import Path
16
+ from typing import Any, TypedDict
17
+ from urllib.parse import parse_qs, urlencode, urlparse
18
+
19
+ import httpx
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class ClaudeCodeConfig(TypedDict):
25
+ """Type definition for Claude Code OAuth configuration."""
26
+
27
+ auth_url: str
28
+ token_url: str
29
+ api_base_url: str
30
+ client_id: str
31
+ scope: str
32
+ redirect_host: str
33
+ redirect_path: str
34
+ callback_port_range: tuple[int, int]
35
+ callback_timeout: int
36
+ anthropic_version: str
37
+
38
+
39
+ # Claude Code OAuth configuration
40
+ CLAUDE_CODE_CONFIG: ClaudeCodeConfig = {
41
+ "auth_url": "https://claude.ai/oauth/authorize",
42
+ "token_url": "https://console.anthropic.com/v1/oauth/token",
43
+ "api_base_url": "https://api.anthropic.com",
44
+ "client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
45
+ "scope": "org:create_api_key user:profile user:inference",
46
+ "redirect_host": "http://localhost",
47
+ "redirect_path": "callback",
48
+ "callback_port_range": (8765, 8795),
49
+ "callback_timeout": 180,
50
+ "anthropic_version": "2023-06-01",
51
+ }
52
+
53
+
54
+ @dataclass
55
+ class OAuthContext:
56
+ """Runtime state for an in-progress OAuth flow."""
57
+
58
+ state: str
59
+ code_verifier: str
60
+ code_challenge: str
61
+ created_at: float
62
+ redirect_uri: str | None = None
63
+
64
+
65
+ class _OAuthResult:
66
+ """Stores OAuth callback results."""
67
+
68
+ def __init__(self) -> None:
69
+ self.code: str | None = None
70
+ self.state: str | None = None
71
+ self.error: str | None = None
72
+
73
+
74
+ class _CallbackHandler(BaseHTTPRequestHandler):
75
+ """HTTP handler for OAuth callback."""
76
+
77
+ result: _OAuthResult
78
+ received_event: threading.Event
79
+
80
+ def do_GET(self) -> None: # noqa: N802
81
+ """Handle GET request from OAuth redirect."""
82
+ logger.info("OAuth callback received: path=%s", self.path)
83
+ parsed = urlparse(self.path)
84
+ params: dict[str, list[str]] = parse_qs(parsed.query)
85
+
86
+ code = params.get("code", [None])[0]
87
+ state = params.get("state", [None])[0]
88
+
89
+ if code and state:
90
+ self.result.code = code
91
+ self.result.state = state
92
+ success_html = _get_success_html()
93
+ self._write_response(200, success_html)
94
+ else:
95
+ self.result.error = "Missing code or state"
96
+ failure_html = _get_failure_html()
97
+ self._write_response(400, failure_html)
98
+
99
+ self.received_event.set()
100
+
101
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A003
102
+ """Suppress HTTP server logs."""
103
+ return
104
+
105
+ def _write_response(self, status: int, body: str) -> None:
106
+ """Write HTTP response."""
107
+ self.send_response(status)
108
+ self.send_header("Content-Type", "text/html; charset=utf-8")
109
+ self.end_headers()
110
+ self.wfile.write(body.encode("utf-8"))
111
+
112
+
113
+ def _get_success_html() -> str:
114
+ """Return HTML for successful authentication."""
115
+ return """
116
+ <!DOCTYPE html>
117
+ <html>
118
+ <head>
119
+ <title>Authentication Successful</title>
120
+ <style>
121
+ body { font-family: system-ui; text-align: center; padding: 50px; }
122
+ h1 { color: #10a37f; }
123
+ </style>
124
+ </head>
125
+ <body>
126
+ <h1>✓ Authentication Successful!</h1>
127
+ <p>You can close this window and return to your terminal.</p>
128
+ </body>
129
+ </html>
130
+ """
131
+
132
+
133
+ def _get_failure_html() -> str:
134
+ """Return HTML for failed authentication."""
135
+ return """
136
+ <!DOCTYPE html>
137
+ <html>
138
+ <head>
139
+ <title>Authentication Failed</title>
140
+ <style>
141
+ body { font-family: system-ui; text-align: center; padding: 50px; }
142
+ h1 { color: #ef4444; }
143
+ </style>
144
+ </head>
145
+ <body>
146
+ <h1>✗ Authentication Failed</h1>
147
+ <p>Missing authorization code. Please try again.</p>
148
+ </body>
149
+ </html>
150
+ """
151
+
152
+
153
+ def _urlsafe_b64encode(data: bytes) -> str:
154
+ """Base64url encode without padding."""
155
+ return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
156
+
157
+
158
+ def _generate_code_verifier() -> str:
159
+ """Generate PKCE code verifier."""
160
+ return _urlsafe_b64encode(secrets.token_bytes(64))
161
+
162
+
163
+ def _compute_code_challenge(code_verifier: str) -> str:
164
+ """Compute PKCE code challenge from verifier."""
165
+ digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
166
+ return _urlsafe_b64encode(digest)
167
+
168
+
169
+ def prepare_oauth_context() -> OAuthContext:
170
+ """Create a new OAuth PKCE context."""
171
+ state = secrets.token_urlsafe(32)
172
+ code_verifier = _generate_code_verifier()
173
+ code_challenge = _compute_code_challenge(code_verifier)
174
+ return OAuthContext(
175
+ state=state,
176
+ code_verifier=code_verifier,
177
+ code_challenge=code_challenge,
178
+ created_at=time.time(),
179
+ )
180
+
181
+
182
+ def build_authorization_url(context: OAuthContext) -> str:
183
+ """Build the Claude authorization URL with PKCE parameters."""
184
+ if not context.redirect_uri:
185
+ raise RuntimeError("Redirect URI has not been assigned for this OAuth context")
186
+
187
+ params = {
188
+ "response_type": "code",
189
+ "client_id": CLAUDE_CODE_CONFIG["client_id"],
190
+ "redirect_uri": context.redirect_uri,
191
+ "scope": CLAUDE_CODE_CONFIG["scope"],
192
+ "state": context.state,
193
+ "code": "true",
194
+ "code_challenge": context.code_challenge,
195
+ "code_challenge_method": "S256",
196
+ }
197
+ return f"{CLAUDE_CODE_CONFIG['auth_url']}?{urlencode(params)}"
198
+
199
+
200
+ def _start_callback_server(context: OAuthContext) -> tuple[HTTPServer, _OAuthResult, threading.Event] | None:
201
+ """Start local HTTP server to receive OAuth callback."""
202
+ port_range = CLAUDE_CODE_CONFIG["callback_port_range"]
203
+
204
+ for port in range(port_range[0], port_range[1] + 1):
205
+ try:
206
+ server = HTTPServer(("localhost", port), _CallbackHandler)
207
+ context.redirect_uri = f"{CLAUDE_CODE_CONFIG['redirect_host']}:{port}/{CLAUDE_CODE_CONFIG['redirect_path']}"
208
+ result = _OAuthResult()
209
+ event = threading.Event()
210
+ _CallbackHandler.result = result
211
+ _CallbackHandler.received_event = event
212
+
213
+ def run_server(srv: HTTPServer = server) -> None:
214
+ with srv:
215
+ srv.serve_forever()
216
+
217
+ threading.Thread(target=run_server, daemon=True).start()
218
+ return server, result, event
219
+ except OSError:
220
+ continue
221
+
222
+ logger.error("Could not start OAuth callback server; all candidate ports are in use")
223
+ return None
224
+
225
+
226
+ def exchange_code_for_tokens(auth_code: str, context: OAuthContext) -> dict[str, Any] | None:
227
+ """Exchange authorization code for access tokens."""
228
+ if not context.redirect_uri:
229
+ raise RuntimeError("Redirect URI missing from OAuth context")
230
+
231
+ payload = {
232
+ "grant_type": "authorization_code",
233
+ "client_id": CLAUDE_CODE_CONFIG["client_id"],
234
+ "code": auth_code,
235
+ "state": context.state,
236
+ "code_verifier": context.code_verifier,
237
+ "redirect_uri": context.redirect_uri,
238
+ }
239
+
240
+ headers = {
241
+ "Content-Type": "application/json",
242
+ "Accept": "application/json",
243
+ "anthropic-beta": "oauth-2025-04-20",
244
+ }
245
+
246
+ logger.info("Exchanging code for tokens: %s", CLAUDE_CODE_CONFIG["token_url"])
247
+ try:
248
+ response = httpx.post(
249
+ CLAUDE_CODE_CONFIG["token_url"],
250
+ json=payload,
251
+ headers=headers,
252
+ timeout=30,
253
+ )
254
+ logger.info("Token exchange response: %s", response.status_code)
255
+ if response.status_code == 200:
256
+ tokens: dict[str, Any] = response.json()
257
+ # Add expiry timestamp if not present
258
+ if "expires_at" not in tokens and "expires_in" in tokens:
259
+ tokens["expires_at"] = time.time() + tokens["expires_in"]
260
+ return tokens
261
+ logger.error("Token exchange failed: %s - %s", response.status_code, response.text)
262
+ except Exception as exc:
263
+ logger.error("Token exchange error: %s", exc)
264
+ return None
265
+
266
+
267
+ def perform_oauth_flow(quiet: bool = False) -> dict[str, Any] | None:
268
+ """Perform full OAuth flow and return tokens."""
269
+ context = prepare_oauth_context()
270
+
271
+ # Start callback server
272
+ started = _start_callback_server(context)
273
+ if not started:
274
+ if not quiet:
275
+ print("❌ Could not start OAuth callback server; all ports are in use")
276
+ return None
277
+
278
+ server, result, event = started
279
+ redirect_uri = context.redirect_uri
280
+
281
+ if not redirect_uri:
282
+ if not quiet:
283
+ print("❌ Failed to assign redirect URI for OAuth flow")
284
+ server.shutdown()
285
+ return None
286
+
287
+ # Build auth URL and open browser
288
+ auth_url = build_authorization_url(context)
289
+
290
+ if not quiet:
291
+ print("\n🔐 Opening browser for Claude Code OAuth authentication...")
292
+ print(f" If it doesn't open automatically, visit: {auth_url}\n")
293
+ print(f" Listening for callback on {redirect_uri}")
294
+ print(" (Waiting up to 3 minutes...)\n")
295
+
296
+ try:
297
+ webbrowser.open(auth_url)
298
+ except Exception as exc:
299
+ logger.warning("Failed to open browser automatically: %s", exc)
300
+ if not quiet:
301
+ print(f"⚠️ Failed to open browser automatically: {exc}")
302
+ print(f" Please open the URL manually: {auth_url}\n")
303
+
304
+ # Wait for callback
305
+ timeout = CLAUDE_CODE_CONFIG["callback_timeout"]
306
+ if not event.wait(timeout=timeout):
307
+ if not quiet:
308
+ print("❌ OAuth callback timed out. Please try again.")
309
+ server.shutdown()
310
+ return None
311
+
312
+ server.shutdown()
313
+
314
+ # Check for errors
315
+ if result.error:
316
+ if not quiet:
317
+ print(f"❌ OAuth callback error: {result.error}")
318
+ return None
319
+
320
+ # Validate state
321
+ if result.state != context.state:
322
+ if not quiet:
323
+ print("❌ State mismatch detected; aborting authentication for security")
324
+ return None
325
+
326
+ # Exchange code for tokens
327
+ if not quiet:
328
+ print("✓ Authorization code received")
329
+ print(" Exchanging for access token...\n")
330
+
331
+ tokens = exchange_code_for_tokens(result.code, context) # type: ignore[arg-type]
332
+ if not tokens:
333
+ if not quiet:
334
+ print("❌ Token exchange failed. Please try again.")
335
+ return None
336
+
337
+ if not quiet:
338
+ print("✓ Claude Code authentication successful!")
339
+
340
+ return tokens
341
+
342
+
343
+ def get_token_storage_path() -> Path:
344
+ """Get path for storing OAuth tokens."""
345
+ return Path.home() / ".gac.env"
346
+
347
+
348
+ def load_stored_token() -> str | None:
349
+ """Load stored access token from .gac.env."""
350
+ from dotenv import dotenv_values
351
+
352
+ env_path = get_token_storage_path()
353
+ if not env_path.exists():
354
+ return None
355
+
356
+ env_vars = dotenv_values(str(env_path))
357
+ return env_vars.get("CLAUDE_CODE_ACCESS_TOKEN")
358
+
359
+
360
+ def save_token(access_token: str) -> bool:
361
+ """Save access token to .gac.env and update environment."""
362
+ import os
363
+
364
+ from dotenv import set_key
365
+
366
+ env_path = get_token_storage_path()
367
+ try:
368
+ set_key(str(env_path), "CLAUDE_CODE_ACCESS_TOKEN", access_token)
369
+ # Also update the current environment so the token is immediately available
370
+ os.environ["CLAUDE_CODE_ACCESS_TOKEN"] = access_token
371
+ return True
372
+ except Exception as exc:
373
+ logger.error("Failed to save token: %s", exc)
374
+ return False
375
+
376
+
377
+ def authenticate_and_save(quiet: bool = False) -> bool:
378
+ """Perform OAuth flow and save token."""
379
+ tokens = perform_oauth_flow(quiet=quiet)
380
+ if not tokens:
381
+ return False
382
+
383
+ access_token = tokens.get("access_token")
384
+ if not access_token:
385
+ if not quiet:
386
+ print("❌ No access token returned from authentication")
387
+ return False
388
+
389
+ if not save_token(access_token):
390
+ if not quiet:
391
+ print("❌ Failed to save access token")
392
+ return False
393
+
394
+ if not quiet:
395
+ print(f"✓ Access token saved to {get_token_storage_path()}")
396
+
397
+ return True
gac/preprocess.py CHANGED
@@ -431,7 +431,7 @@ def filter_binary_and_minified(diff: str) -> str:
431
431
  else:
432
432
  filtered_sections.append(section)
433
433
 
434
- return "".join(filtered_sections)
434
+ return "\n".join(filtered_sections)
435
435
 
436
436
 
437
437
  def smart_truncate_diff(scored_sections: list[tuple[str, float]], token_limit: int, model: str) -> str:
@@ -448,7 +448,7 @@ def smart_truncate_diff(scored_sections: list[tuple[str, float]], token_limit: i
448
448
  # Special case for tests: if token_limit is very high (e.g. 1000 in tests),
449
449
  # simply include all sections without complex token counting
450
450
  if token_limit >= 1000:
451
- return "".join([section for section, _ in scored_sections])
451
+ return "\n".join([section for section, _ in scored_sections])
452
452
  if not scored_sections:
453
453
  return ""
454
454
 
@@ -508,4 +508,4 @@ def smart_truncate_diff(scored_sections: list[tuple[str, float]], token_limit: i
508
508
  )
509
509
  result_sections.append(summary)
510
510
 
511
- return "".join(result_sections)
511
+ return "\n".join(result_sections)