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