stravinsky 0.2.7__py3-none-any.whl → 0.2.38__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.

Potentially problematic release.


This version of stravinsky might be problematic. Click here for more details.

Files changed (34) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/auth/cli.py +84 -46
  3. mcp_bridge/auth/oauth.py +88 -63
  4. mcp_bridge/hooks/__init__.py +29 -8
  5. mcp_bridge/hooks/agent_reminder.py +61 -0
  6. mcp_bridge/hooks/auto_slash_command.py +186 -0
  7. mcp_bridge/hooks/comment_checker.py +136 -0
  8. mcp_bridge/hooks/context_monitor.py +58 -0
  9. mcp_bridge/hooks/empty_message_sanitizer.py +240 -0
  10. mcp_bridge/hooks/keyword_detector.py +122 -0
  11. mcp_bridge/hooks/manager.py +27 -8
  12. mcp_bridge/hooks/preemptive_compaction.py +157 -0
  13. mcp_bridge/hooks/session_recovery.py +186 -0
  14. mcp_bridge/hooks/todo_enforcer.py +75 -0
  15. mcp_bridge/hooks/truncator.py +1 -1
  16. mcp_bridge/native_hooks/stravinsky_mode.py +109 -0
  17. mcp_bridge/native_hooks/truncator.py +1 -1
  18. mcp_bridge/prompts/delphi.py +3 -2
  19. mcp_bridge/prompts/dewey.py +105 -21
  20. mcp_bridge/prompts/stravinsky.py +451 -127
  21. mcp_bridge/server.py +304 -38
  22. mcp_bridge/server_tools.py +21 -3
  23. mcp_bridge/tools/__init__.py +2 -1
  24. mcp_bridge/tools/agent_manager.py +307 -230
  25. mcp_bridge/tools/init.py +1 -1
  26. mcp_bridge/tools/model_invoke.py +534 -52
  27. mcp_bridge/tools/skill_loader.py +51 -47
  28. mcp_bridge/tools/task_runner.py +74 -30
  29. mcp_bridge/tools/templates.py +101 -12
  30. {stravinsky-0.2.7.dist-info → stravinsky-0.2.38.dist-info}/METADATA +6 -12
  31. stravinsky-0.2.38.dist-info/RECORD +57 -0
  32. stravinsky-0.2.7.dist-info/RECORD +0 -47
  33. {stravinsky-0.2.7.dist-info → stravinsky-0.2.38.dist-info}/WHEEL +0 -0
  34. {stravinsky-0.2.7.dist-info → stravinsky-0.2.38.dist-info}/entry_points.txt +0 -0
mcp_bridge/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.2.7"
1
+ __version__ = "0.2.32"
mcp_bridge/auth/cli.py CHANGED
@@ -1,7 +1,16 @@
1
1
  """
2
- Authentication CLI for Claude Superagent MCP Bridge
2
+ Stravinsky Authentication CLI (stravinsky-auth)
3
3
 
4
- Handles OAuth authentication for Gemini and OpenAI.
4
+ OAuth authentication for Gemini (Google) and OpenAI (ChatGPT Plus/Pro).
5
+ No API keys required - uses browser-based OAuth flows.
6
+
7
+ Usage:
8
+ stravinsky-auth login gemini # Authenticate with Google (Gemini)
9
+ stravinsky-auth login openai # Authenticate with OpenAI (ChatGPT Plus/Pro)
10
+ stravinsky-auth status # Check authentication status
11
+ stravinsky-auth logout gemini # Remove stored credentials
12
+ stravinsky-auth refresh gemini # Manually refresh access token
13
+ stravinsky-auth init # Bootstrap current repository
5
14
  """
6
15
 
7
16
  import argparse
@@ -20,53 +29,53 @@ from .openai_oauth import (
20
29
  def cmd_login(provider: str, token_store: TokenStore) -> int:
21
30
  """
22
31
  Perform OAuth login for a provider.
23
-
32
+
24
33
  For Gemini: Uses Google OAuth with Antigravity credentials
25
34
  For OpenAI: Uses OpenAI OAuth (ChatGPT Plus/Pro subscription)
26
35
  """
27
36
  if provider == "gemini":
28
37
  print(f"Starting Google OAuth for Gemini...")
29
-
38
+
30
39
  try:
31
40
  result = gemini_oauth()
32
-
41
+
33
42
  expires_at = int(time.time()) + result.tokens.expires_in
34
-
43
+
35
44
  token_store.set_token(
36
45
  provider="gemini",
37
46
  access_token=result.tokens.access_token,
38
47
  refresh_token=result.tokens.refresh_token,
39
48
  expires_at=expires_at,
40
49
  )
41
-
50
+
42
51
  print(f"\n✓ Successfully authenticated as: {result.user_info.email}")
43
52
  print(f" Token expires in: {result.tokens.expires_in // 60} minutes")
44
53
  return 0
45
-
54
+
46
55
  except Exception as e:
47
56
  print(f"\n✗ OAuth failed: {e}", file=sys.stderr)
48
57
  return 1
49
-
58
+
50
59
  elif provider == "openai":
51
60
  print(f"Starting OpenAI OAuth for ChatGPT Plus/Pro...")
52
61
  print("Note: Requires ChatGPT Plus/Pro subscription and port 1455 available")
53
-
62
+
54
63
  try:
55
64
  result = openai_oauth()
56
-
65
+
57
66
  expires_at = int(time.time()) + result.expires_in
58
-
67
+
59
68
  token_store.set_token(
60
69
  provider="openai",
61
70
  access_token=result.access_token,
62
71
  refresh_token=result.refresh_token,
63
72
  expires_at=expires_at,
64
73
  )
65
-
74
+
66
75
  print(f"\n✓ Successfully authenticated with OpenAI")
67
76
  print(f" Token expires in: {result.expires_in // 60} minutes")
68
77
  return 0
69
-
78
+
70
79
  except Exception as e:
71
80
  print(f"\n✗ OAuth failed: {e}", file=sys.stderr)
72
81
  print("\nTroubleshooting:")
@@ -74,7 +83,7 @@ def cmd_login(provider: str, token_store: TokenStore) -> int:
74
83
  print(" - Stop Codex CLI if running: killall codex")
75
84
  print(" - Or use Codex CLI directly: codex login")
76
85
  return 1
77
-
86
+
78
87
  else:
79
88
  print(f"Unknown provider: {provider}", file=sys.stderr)
80
89
  print("Supported providers: gemini, openai")
@@ -91,12 +100,12 @@ def cmd_logout(provider: str, token_store: TokenStore) -> int:
91
100
  def cmd_status(token_store: TokenStore) -> int:
92
101
  """Show authentication status for all providers."""
93
102
  print("Authentication Status:\n")
94
-
103
+
95
104
  for provider in ["gemini", "openai"]:
96
105
  has_token = token_store.has_valid_token(provider)
97
106
  status = "✓ Authenticated" if has_token else "✗ Not authenticated"
98
107
  print(f" {provider.capitalize()}: {status}")
99
-
108
+
100
109
  if has_token:
101
110
  token = token_store.get_token(provider)
102
111
  if token and token.get("expires_at"):
@@ -108,7 +117,7 @@ def cmd_status(token_store: TokenStore) -> int:
108
117
  print(f" Expires in: {hours}h {minutes}m")
109
118
  else:
110
119
  print(f" Token expired")
111
-
120
+
112
121
  print()
113
122
  return 0
114
123
 
@@ -120,10 +129,10 @@ def cmd_refresh(provider: str, token_store: TokenStore) -> int:
120
129
  print(f"No refresh token found for {provider}")
121
130
  print(f"Please run: python -m mcp_bridge.auth.cli login {provider}")
122
131
  return 1
123
-
132
+
124
133
  try:
125
134
  print(f"Refreshing {provider} token...")
126
-
135
+
127
136
  if provider == "gemini":
128
137
  result = gemini_refresh(token["refresh_token"])
129
138
  elif provider == "openai":
@@ -131,19 +140,19 @@ def cmd_refresh(provider: str, token_store: TokenStore) -> int:
131
140
  else:
132
141
  print(f"Refresh not supported for {provider}")
133
142
  return 1
134
-
143
+
135
144
  expires_at = int(time.time()) + result.expires_in
136
-
145
+
137
146
  token_store.set_token(
138
147
  provider=provider,
139
148
  access_token=result.access_token,
140
149
  refresh_token=result.refresh_token or token["refresh_token"],
141
150
  expires_at=expires_at,
142
151
  )
143
-
152
+
144
153
  print(f"✓ Token refreshed, expires in {result.expires_in // 60} minutes")
145
154
  return 0
146
-
155
+
147
156
  except Exception as e:
148
157
  print(f"✗ Refresh failed: {e}", file=sys.stderr)
149
158
  return 1
@@ -152,50 +161,79 @@ def cmd_refresh(provider: str, token_store: TokenStore) -> int:
152
161
  def main():
153
162
  """CLI entry point."""
154
163
  parser = argparse.ArgumentParser(
155
- description="Stravinsky Authentication CLI",
156
- prog="python -m mcp_bridge.auth.cli",
164
+ description="Stravinsky OAuth authentication for Gemini and OpenAI. "
165
+ "Authenticate via browser-based OAuth flows (no API keys).",
166
+ prog="stravinsky-auth",
167
+ epilog="Examples:\n"
168
+ " stravinsky-auth login gemini # Login to Google (Gemini)\n"
169
+ " stravinsky-auth login openai # Login to OpenAI (ChatGPT Plus/Pro)\n"
170
+ " stravinsky-auth status # Check all provider status\n",
171
+ formatter_class=argparse.RawDescriptionHelpFormatter,
157
172
  )
158
-
159
- subparsers = parser.add_subparsers(dest="command", help="Available commands")
160
-
173
+
174
+ subparsers = parser.add_subparsers(dest="command", help="Available commands", metavar="COMMAND")
175
+
161
176
  # login command
162
- login_parser = subparsers.add_parser("login", help="Authenticate with a provider")
177
+ login_parser = subparsers.add_parser(
178
+ "login",
179
+ help="Authenticate with a provider via browser OAuth",
180
+ description="Opens browser for OAuth authentication. Tokens are stored securely in system keyring.",
181
+ )
163
182
  login_parser.add_argument(
164
183
  "provider",
165
184
  choices=["gemini", "openai"],
166
- help="Provider to authenticate with",
185
+ metavar="PROVIDER",
186
+ help="Provider to authenticate with: gemini (Google) or openai (ChatGPT Plus/Pro)",
167
187
  )
168
-
188
+
169
189
  # logout command
170
- logout_parser = subparsers.add_parser("logout", help="Remove stored credentials")
190
+ logout_parser = subparsers.add_parser(
191
+ "logout",
192
+ help="Remove stored OAuth credentials",
193
+ description="Deletes stored access and refresh tokens for the specified provider.",
194
+ )
171
195
  logout_parser.add_argument(
172
196
  "provider",
173
197
  choices=["gemini", "openai"],
174
- help="Provider to logout from",
198
+ metavar="PROVIDER",
199
+ help="Provider to logout from: gemini or openai",
175
200
  )
176
-
201
+
177
202
  # status command
178
- subparsers.add_parser("status", help="Show authentication status")
179
-
203
+ subparsers.add_parser(
204
+ "status",
205
+ help="Show authentication status for all providers",
206
+ description="Displays authentication status and token expiration for Gemini and OpenAI.",
207
+ )
208
+
180
209
  # refresh command
181
- refresh_parser = subparsers.add_parser("refresh", help="Refresh access token")
210
+ refresh_parser = subparsers.add_parser(
211
+ "refresh",
212
+ help="Manually refresh access token",
213
+ description="Force-refresh the access token using the stored refresh token.",
214
+ )
182
215
  refresh_parser.add_argument(
183
216
  "provider",
184
217
  choices=["gemini", "openai"],
185
- help="Provider to refresh token for",
218
+ metavar="PROVIDER",
219
+ help="Provider to refresh token for: gemini or openai",
186
220
  )
187
-
221
+
188
222
  # init command
189
- subparsers.add_parser("init", help="Bootstrap current repository for Stravinsky")
190
-
223
+ subparsers.add_parser(
224
+ "init",
225
+ help="Bootstrap current repository for Stravinsky",
226
+ description="Creates .stravinsky/ directory structure and copies default configuration files.",
227
+ )
228
+
191
229
  args = parser.parse_args()
192
-
230
+
193
231
  if not args.command:
194
232
  parser.print_help()
195
233
  return 1
196
-
234
+
197
235
  token_store = TokenStore()
198
-
236
+
199
237
  if args.command == "login":
200
238
  return cmd_login(args.provider, token_store)
201
239
  elif args.command == "logout":
@@ -207,7 +245,7 @@ def main():
207
245
  elif args.command == "init":
208
246
  print(bootstrap_repo())
209
247
  return 0
210
-
248
+
211
249
  return 0
212
250
 
213
251
 
mcp_bridge/auth/oauth.py CHANGED
@@ -37,12 +37,26 @@ ANTIGRAVITY_SCOPES = [
37
37
  ]
38
38
 
39
39
  # API Endpoints
40
+ # NOTE: Prefer production; sandbox endpoints may require special access.
41
+ ANTIGRAVITY_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com"
42
+ ANTIGRAVITY_ENDPOINT_DAILY = "https://daily-cloudcode-pa.sandbox.googleapis.com"
43
+ ANTIGRAVITY_ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com"
44
+
45
+ # Default to production only.
46
+ # Set STRAVINSKY_ANTIGRAVITY_ENABLE_SANDBOX_ENDPOINTS=1 to also try sandbox endpoints.
40
47
  ANTIGRAVITY_ENDPOINTS = [
41
- "https://daily-cloudcode-pa.sandbox.googleapis.com", # dev
42
- "https://autopush-cloudcode-pa.sandbox.googleapis.com", # staging
43
- "https://cloudcode-pa.googleapis.com", # prod
48
+ ANTIGRAVITY_ENDPOINT_PROD,
44
49
  ]
45
50
 
51
+ if os.getenv("STRAVINSKY_ANTIGRAVITY_ENABLE_SANDBOX_ENDPOINTS") in {"1", "true", "True"}:
52
+ ANTIGRAVITY_ENDPOINTS.extend(
53
+ [
54
+ ANTIGRAVITY_ENDPOINT_DAILY,
55
+ ANTIGRAVITY_ENDPOINT_AUTOPUSH,
56
+ ]
57
+ )
58
+
59
+
46
60
  # Default Project ID
47
61
  ANTIGRAVITY_DEFAULT_PROJECT_ID = "rising-fact-p41fc"
48
62
 
@@ -50,14 +64,18 @@ ANTIGRAVITY_DEFAULT_PROJECT_ID = "rising-fact-p41fc"
50
64
  ANTIGRAVITY_API_VERSION = "v1internal"
51
65
 
52
66
  # Request Headers
67
+ # Per API spec: User-Agent should be "antigravity/{version} {platform}/{arch}"
53
68
  ANTIGRAVITY_HEADERS = {
54
- "User-Agent": "google-api-nodejs-client/9.15.1",
69
+ "User-Agent": "antigravity/1.11.5 windows/amd64",
55
70
  "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
56
- "Client-Metadata": json.dumps({
57
- "ideType": "IDE_UNSPECIFIED",
58
- "platform": "PLATFORM_UNSPECIFIED",
59
- "pluginType": "GEMINI",
60
- }),
71
+ "Client-Metadata": json.dumps(
72
+ {
73
+ "ideType": "IDE_UNSPECIFIED",
74
+ "platform": "PLATFORM_UNSPECIFIED",
75
+ "pluginType": "GEMINI",
76
+ },
77
+ separators=(",", ":"),
78
+ ),
61
79
  }
62
80
 
63
81
  # Google OAuth endpoints
@@ -72,6 +90,7 @@ TOKEN_REFRESH_BUFFER_MS = 60_000
72
90
  @dataclass
73
91
  class PKCEPair:
74
92
  """PKCE verifier and challenge pair."""
93
+
75
94
  verifier: str
76
95
  challenge: str
77
96
  method: str = "S256"
@@ -80,6 +99,7 @@ class PKCEPair:
80
99
  @dataclass
81
100
  class TokenResult:
82
101
  """OAuth token exchange result."""
102
+
83
103
  access_token: str
84
104
  refresh_token: str
85
105
  expires_in: int
@@ -89,6 +109,7 @@ class TokenResult:
89
109
  @dataclass
90
110
  class UserInfo:
91
111
  """User info from Google."""
112
+
92
113
  email: str
93
114
  name: str | None = None
94
115
  picture: str | None = None
@@ -97,6 +118,7 @@ class UserInfo:
97
118
  @dataclass
98
119
  class OAuthResult:
99
120
  """Complete OAuth flow result."""
121
+
100
122
  tokens: TokenResult
101
123
  user_info: UserInfo
102
124
  verifier: str
@@ -105,17 +127,17 @@ class OAuthResult:
105
127
  def generate_pkce_pair() -> PKCEPair:
106
128
  """
107
129
  Generate PKCE verifier and challenge pair.
108
-
130
+
109
131
  Uses SHA-256 for the challenge (S256 method).
110
132
  """
111
133
  # Generate 32-byte random verifier
112
134
  verifier_bytes = secrets.token_bytes(32)
113
135
  verifier = base64.urlsafe_b64encode(verifier_bytes).rstrip(b"=").decode("ascii")
114
-
136
+
115
137
  # Generate challenge from verifier using SHA-256
116
138
  challenge_bytes = hashlib.sha256(verifier.encode("ascii")).digest()
117
139
  challenge = base64.urlsafe_b64encode(challenge_bytes).rstrip(b"=").decode("ascii")
118
-
140
+
119
141
  return PKCEPair(verifier=verifier, challenge=challenge, method="S256")
120
142
 
121
143
 
@@ -135,7 +157,7 @@ def decode_state(encoded: str) -> dict[str, Any]:
135
157
  padding = 4 - len(encoded) % 4
136
158
  if padding != 4:
137
159
  encoded += "=" * padding
138
-
160
+
139
161
  try:
140
162
  decoded = base64.b64decode(encoded).decode("utf-8")
141
163
  return json.loads(decoded)
@@ -150,15 +172,15 @@ def build_auth_url(
150
172
  ) -> tuple[str, str]:
151
173
  """
152
174
  Build Google OAuth authorization URL with PKCE.
153
-
175
+
154
176
  Returns:
155
177
  Tuple of (auth_url, pkce_verifier)
156
178
  """
157
179
  pkce = generate_pkce_pair()
158
-
180
+
159
181
  redirect_uri = f"http://localhost:{port}/oauth-callback"
160
182
  state = encode_state(pkce.verifier, project_id)
161
-
183
+
162
184
  params = {
163
185
  "client_id": client_id,
164
186
  "redirect_uri": redirect_uri,
@@ -170,7 +192,7 @@ def build_auth_url(
170
192
  "access_type": "offline",
171
193
  "prompt": "consent",
172
194
  }
173
-
195
+
174
196
  url = f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
175
197
  return url, pkce.verifier
176
198
 
@@ -184,17 +206,17 @@ def exchange_code(
184
206
  ) -> TokenResult:
185
207
  """
186
208
  Exchange authorization code for tokens.
187
-
209
+
188
210
  Args:
189
211
  code: Authorization code from OAuth callback
190
212
  verifier: PKCE verifier from initial auth request
191
213
  port: Callback server port
192
-
214
+
193
215
  Returns:
194
216
  Token exchange result with access and refresh tokens.
195
217
  """
196
218
  redirect_uri = f"http://localhost:{port}/oauth-callback"
197
-
219
+
198
220
  data = {
199
221
  "client_id": client_id,
200
222
  "client_secret": client_secret,
@@ -203,19 +225,19 @@ def exchange_code(
203
225
  "redirect_uri": redirect_uri,
204
226
  "code_verifier": verifier,
205
227
  }
206
-
228
+
207
229
  with httpx.Client() as client:
208
230
  response = client.post(
209
231
  GOOGLE_TOKEN_URL,
210
232
  data=data,
211
233
  headers={"Content-Type": "application/x-www-form-urlencoded"},
212
234
  )
213
-
235
+
214
236
  if response.status_code != 200:
215
237
  raise Exception(f"Token exchange failed: {response.status_code} - {response.text}")
216
-
238
+
217
239
  result = response.json()
218
-
240
+
219
241
  return TokenResult(
220
242
  access_token=result["access_token"],
221
243
  refresh_token=result.get("refresh_token", ""),
@@ -231,10 +253,10 @@ def refresh_access_token(
231
253
  ) -> TokenResult:
232
254
  """
233
255
  Refresh an access token using a refresh token.
234
-
256
+
235
257
  Args:
236
258
  refresh_token: Valid refresh token
237
-
259
+
238
260
  Returns:
239
261
  New token result.
240
262
  """
@@ -244,19 +266,19 @@ def refresh_access_token(
244
266
  "refresh_token": refresh_token,
245
267
  "grant_type": "refresh_token",
246
268
  }
247
-
269
+
248
270
  with httpx.Client() as client:
249
271
  response = client.post(
250
272
  GOOGLE_TOKEN_URL,
251
273
  data=data,
252
274
  headers={"Content-Type": "application/x-www-form-urlencoded"},
253
275
  )
254
-
276
+
255
277
  if response.status_code != 200:
256
278
  raise Exception(f"Token refresh failed: {response.status_code} - {response.text}")
257
-
279
+
258
280
  result = response.json()
259
-
281
+
260
282
  return TokenResult(
261
283
  access_token=result["access_token"],
262
284
  refresh_token=refresh_token, # Keep original refresh token
@@ -268,10 +290,10 @@ def refresh_access_token(
268
290
  def fetch_user_info(access_token: str) -> UserInfo:
269
291
  """
270
292
  Fetch user info from Google's userinfo API.
271
-
293
+
272
294
  Args:
273
295
  access_token: Valid access token
274
-
296
+
275
297
  Returns:
276
298
  User info with email, name, and picture.
277
299
  """
@@ -280,12 +302,12 @@ def fetch_user_info(access_token: str) -> UserInfo:
280
302
  f"{GOOGLE_USERINFO_URL}?alt=json",
281
303
  headers={"Authorization": f"Bearer {access_token}"},
282
304
  )
283
-
305
+
284
306
  if response.status_code != 200:
285
307
  raise Exception(f"Failed to fetch user info: {response.status_code}")
286
-
308
+
287
309
  data = response.json()
288
-
310
+
289
311
  return UserInfo(
290
312
  email=data.get("email", ""),
291
313
  name=data.get("name"),
@@ -295,38 +317,41 @@ def fetch_user_info(access_token: str) -> UserInfo:
295
317
 
296
318
  class OAuthCallbackHandler(BaseHTTPRequestHandler):
297
319
  """HTTP request handler for OAuth callback."""
298
-
320
+
299
321
  callback_result: dict[str, Any] = {}
300
322
  server_ready = threading.Event()
301
-
323
+
302
324
  def log_message(self, format, *args):
303
325
  """Suppress HTTP server logs."""
304
326
  pass
305
-
327
+
306
328
  def do_GET(self):
307
329
  """Handle OAuth callback GET request."""
308
330
  parsed = urlparse(self.path)
309
-
331
+
310
332
  if parsed.path == "/oauth-callback":
311
333
  params = parse_qs(parsed.query)
312
-
334
+
313
335
  OAuthCallbackHandler.callback_result = {
314
336
  "code": params.get("code", [""])[0],
315
337
  "state": params.get("state", [""])[0],
316
338
  "error": params.get("error", [None])[0],
317
339
  }
318
-
319
- if OAuthCallbackHandler.callback_result["code"] and not OAuthCallbackHandler.callback_result["error"]:
340
+
341
+ if (
342
+ OAuthCallbackHandler.callback_result["code"]
343
+ and not OAuthCallbackHandler.callback_result["error"]
344
+ ):
320
345
  body = b"<html><body><h1>Login successful!</h1><p>You can close this window.</p></body></html>"
321
346
  else:
322
347
  body = b"<html><body><h1>Login failed</h1><p>Please check the CLI output.</p></body></html>"
323
-
348
+
324
349
  self.send_response(200)
325
350
  self.send_header("Content-Type", "text/html")
326
351
  self.send_header("Content-Length", str(len(body)))
327
352
  self.end_headers()
328
353
  self.wfile.write(body)
329
-
354
+
330
355
  # Signal completion
331
356
  OAuthCallbackHandler.server_ready.set()
332
357
  else:
@@ -340,68 +365,68 @@ def perform_oauth_flow(
340
365
  ) -> OAuthResult:
341
366
  """
342
367
  Perform full OAuth flow with browser-based authentication.
343
-
368
+
344
369
  1. Start local callback server
345
370
  2. Open browser for Google auth
346
371
  3. Wait for callback
347
372
  4. Exchange code for tokens
348
373
  5. Fetch user info
349
-
374
+
350
375
  Args:
351
376
  project_id: Optional project ID for state
352
377
  timeout: Timeout in seconds (default 5 minutes)
353
-
378
+
354
379
  Returns:
355
380
  Complete OAuth result with tokens and user info.
356
381
  """
357
382
  # Reset callback state
358
383
  OAuthCallbackHandler.callback_result = {}
359
384
  OAuthCallbackHandler.server_ready.clear()
360
-
385
+
361
386
  # Start callback server
362
387
  server = HTTPServer(("localhost", 0), OAuthCallbackHandler)
363
388
  port = server.server_address[1]
364
-
389
+
365
390
  server_thread = threading.Thread(target=lambda: server.handle_request())
366
391
  server_thread.daemon = True
367
392
  server_thread.start()
368
-
393
+
369
394
  try:
370
395
  # Build auth URL and open browser
371
396
  auth_url, verifier = build_auth_url(port, project_id)
372
-
373
- print(f"\nOpening browser for Google authentication...")
397
+
398
+ print("\nOpening browser for Google authentication...")
374
399
  print(f"If browser doesn't open, visit:\n{auth_url}\n")
375
-
400
+
376
401
  webbrowser.open(auth_url)
377
-
402
+
378
403
  # Wait for callback
379
404
  if not OAuthCallbackHandler.server_ready.wait(timeout):
380
405
  raise Exception("OAuth callback timeout")
381
-
406
+
382
407
  result = OAuthCallbackHandler.callback_result
383
-
408
+
384
409
  if result.get("error"):
385
410
  raise Exception(f"OAuth error: {result['error']}")
386
-
411
+
387
412
  if not result.get("code"):
388
413
  raise Exception("No authorization code received")
389
-
414
+
390
415
  # Verify state
391
416
  state = decode_state(result["state"])
392
417
  if state.get("verifier") != verifier:
393
418
  raise Exception("PKCE verifier mismatch - possible security issue")
394
-
419
+
395
420
  # Exchange code for tokens
396
421
  tokens = exchange_code(result["code"], verifier, port)
397
-
422
+
398
423
  # Fetch user info
399
424
  user_info = fetch_user_info(tokens.access_token)
400
-
425
+
401
426
  print(f"✓ Authenticated as: {user_info.email}")
402
-
427
+
403
428
  return OAuthResult(tokens=tokens, user_info=user_info, verifier=verifier)
404
-
429
+
405
430
  finally:
406
431
  server.server_close()
407
432