stravinsky 0.1.2__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 (42) hide show
  1. mcp_bridge/__init__.py +1 -5
  2. mcp_bridge/auth/cli.py +89 -44
  3. mcp_bridge/auth/oauth.py +88 -63
  4. mcp_bridge/hooks/__init__.py +49 -0
  5. mcp_bridge/hooks/agent_reminder.py +61 -0
  6. mcp_bridge/hooks/auto_slash_command.py +186 -0
  7. mcp_bridge/hooks/budget_optimizer.py +38 -0
  8. mcp_bridge/hooks/comment_checker.py +136 -0
  9. mcp_bridge/hooks/compaction.py +32 -0
  10. mcp_bridge/hooks/context_monitor.py +58 -0
  11. mcp_bridge/hooks/directory_context.py +40 -0
  12. mcp_bridge/hooks/edit_recovery.py +41 -0
  13. mcp_bridge/hooks/empty_message_sanitizer.py +240 -0
  14. mcp_bridge/hooks/keyword_detector.py +122 -0
  15. mcp_bridge/hooks/manager.py +96 -0
  16. mcp_bridge/hooks/preemptive_compaction.py +157 -0
  17. mcp_bridge/hooks/session_recovery.py +186 -0
  18. mcp_bridge/hooks/todo_enforcer.py +75 -0
  19. mcp_bridge/hooks/truncator.py +19 -0
  20. mcp_bridge/native_hooks/context.py +38 -0
  21. mcp_bridge/native_hooks/edit_recovery.py +46 -0
  22. mcp_bridge/native_hooks/stravinsky_mode.py +109 -0
  23. mcp_bridge/native_hooks/truncator.py +23 -0
  24. mcp_bridge/prompts/delphi.py +3 -2
  25. mcp_bridge/prompts/dewey.py +105 -21
  26. mcp_bridge/prompts/stravinsky.py +452 -118
  27. mcp_bridge/server.py +491 -668
  28. mcp_bridge/server_tools.py +547 -0
  29. mcp_bridge/tools/__init__.py +13 -3
  30. mcp_bridge/tools/agent_manager.py +359 -190
  31. mcp_bridge/tools/continuous_loop.py +67 -0
  32. mcp_bridge/tools/init.py +50 -0
  33. mcp_bridge/tools/lsp/tools.py +15 -15
  34. mcp_bridge/tools/model_invoke.py +594 -48
  35. mcp_bridge/tools/skill_loader.py +51 -47
  36. mcp_bridge/tools/task_runner.py +141 -0
  37. mcp_bridge/tools/templates.py +175 -0
  38. {stravinsky-0.1.2.dist-info → stravinsky-0.2.38.dist-info}/METADATA +55 -10
  39. stravinsky-0.2.38.dist-info/RECORD +57 -0
  40. stravinsky-0.1.2.dist-info/RECORD +0 -32
  41. {stravinsky-0.1.2.dist-info → stravinsky-0.2.38.dist-info}/WHEEL +0 -0
  42. {stravinsky-0.1.2.dist-info → stravinsky-0.2.38.dist-info}/entry_points.txt +0 -0
mcp_bridge/__init__.py CHANGED
@@ -1,5 +1 @@
1
- # Stravinsky MCP Bridge
2
- # Provides MCP tools for OAuth-authenticated access to Gemini and OpenAI models
3
-
4
- __version__ = "0.1.0"
5
- __author__ = "David Andrews"
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
@@ -9,6 +18,7 @@ import sys
9
18
  import time
10
19
 
11
20
  from .token_store import TokenStore
21
+ from ..tools.init import bootstrap_repo
12
22
  from .oauth import perform_oauth_flow as gemini_oauth, refresh_access_token as gemini_refresh
13
23
  from .openai_oauth import (
14
24
  perform_oauth_flow as openai_oauth,
@@ -19,53 +29,53 @@ from .openai_oauth import (
19
29
  def cmd_login(provider: str, token_store: TokenStore) -> int:
20
30
  """
21
31
  Perform OAuth login for a provider.
22
-
32
+
23
33
  For Gemini: Uses Google OAuth with Antigravity credentials
24
34
  For OpenAI: Uses OpenAI OAuth (ChatGPT Plus/Pro subscription)
25
35
  """
26
36
  if provider == "gemini":
27
37
  print(f"Starting Google OAuth for Gemini...")
28
-
38
+
29
39
  try:
30
40
  result = gemini_oauth()
31
-
41
+
32
42
  expires_at = int(time.time()) + result.tokens.expires_in
33
-
43
+
34
44
  token_store.set_token(
35
45
  provider="gemini",
36
46
  access_token=result.tokens.access_token,
37
47
  refresh_token=result.tokens.refresh_token,
38
48
  expires_at=expires_at,
39
49
  )
40
-
50
+
41
51
  print(f"\n✓ Successfully authenticated as: {result.user_info.email}")
42
52
  print(f" Token expires in: {result.tokens.expires_in // 60} minutes")
43
53
  return 0
44
-
54
+
45
55
  except Exception as e:
46
56
  print(f"\n✗ OAuth failed: {e}", file=sys.stderr)
47
57
  return 1
48
-
58
+
49
59
  elif provider == "openai":
50
60
  print(f"Starting OpenAI OAuth for ChatGPT Plus/Pro...")
51
61
  print("Note: Requires ChatGPT Plus/Pro subscription and port 1455 available")
52
-
62
+
53
63
  try:
54
64
  result = openai_oauth()
55
-
65
+
56
66
  expires_at = int(time.time()) + result.expires_in
57
-
67
+
58
68
  token_store.set_token(
59
69
  provider="openai",
60
70
  access_token=result.access_token,
61
71
  refresh_token=result.refresh_token,
62
72
  expires_at=expires_at,
63
73
  )
64
-
74
+
65
75
  print(f"\n✓ Successfully authenticated with OpenAI")
66
76
  print(f" Token expires in: {result.expires_in // 60} minutes")
67
77
  return 0
68
-
78
+
69
79
  except Exception as e:
70
80
  print(f"\n✗ OAuth failed: {e}", file=sys.stderr)
71
81
  print("\nTroubleshooting:")
@@ -73,7 +83,7 @@ def cmd_login(provider: str, token_store: TokenStore) -> int:
73
83
  print(" - Stop Codex CLI if running: killall codex")
74
84
  print(" - Or use Codex CLI directly: codex login")
75
85
  return 1
76
-
86
+
77
87
  else:
78
88
  print(f"Unknown provider: {provider}", file=sys.stderr)
79
89
  print("Supported providers: gemini, openai")
@@ -90,12 +100,12 @@ def cmd_logout(provider: str, token_store: TokenStore) -> int:
90
100
  def cmd_status(token_store: TokenStore) -> int:
91
101
  """Show authentication status for all providers."""
92
102
  print("Authentication Status:\n")
93
-
103
+
94
104
  for provider in ["gemini", "openai"]:
95
105
  has_token = token_store.has_valid_token(provider)
96
106
  status = "✓ Authenticated" if has_token else "✗ Not authenticated"
97
107
  print(f" {provider.capitalize()}: {status}")
98
-
108
+
99
109
  if has_token:
100
110
  token = token_store.get_token(provider)
101
111
  if token and token.get("expires_at"):
@@ -107,7 +117,7 @@ def cmd_status(token_store: TokenStore) -> int:
107
117
  print(f" Expires in: {hours}h {minutes}m")
108
118
  else:
109
119
  print(f" Token expired")
110
-
120
+
111
121
  print()
112
122
  return 0
113
123
 
@@ -119,10 +129,10 @@ def cmd_refresh(provider: str, token_store: TokenStore) -> int:
119
129
  print(f"No refresh token found for {provider}")
120
130
  print(f"Please run: python -m mcp_bridge.auth.cli login {provider}")
121
131
  return 1
122
-
132
+
123
133
  try:
124
134
  print(f"Refreshing {provider} token...")
125
-
135
+
126
136
  if provider == "gemini":
127
137
  result = gemini_refresh(token["refresh_token"])
128
138
  elif provider == "openai":
@@ -130,19 +140,19 @@ def cmd_refresh(provider: str, token_store: TokenStore) -> int:
130
140
  else:
131
141
  print(f"Refresh not supported for {provider}")
132
142
  return 1
133
-
143
+
134
144
  expires_at = int(time.time()) + result.expires_in
135
-
145
+
136
146
  token_store.set_token(
137
147
  provider=provider,
138
148
  access_token=result.access_token,
139
149
  refresh_token=result.refresh_token or token["refresh_token"],
140
150
  expires_at=expires_at,
141
151
  )
142
-
152
+
143
153
  print(f"✓ Token refreshed, expires in {result.expires_in // 60} minutes")
144
154
  return 0
145
-
155
+
146
156
  except Exception as e:
147
157
  print(f"✗ Refresh failed: {e}", file=sys.stderr)
148
158
  return 1
@@ -151,47 +161,79 @@ def cmd_refresh(provider: str, token_store: TokenStore) -> int:
151
161
  def main():
152
162
  """CLI entry point."""
153
163
  parser = argparse.ArgumentParser(
154
- description="Stravinsky Authentication CLI",
155
- 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,
156
172
  )
157
-
158
- subparsers = parser.add_subparsers(dest="command", help="Available commands")
159
-
173
+
174
+ subparsers = parser.add_subparsers(dest="command", help="Available commands", metavar="COMMAND")
175
+
160
176
  # login command
161
- 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
+ )
162
182
  login_parser.add_argument(
163
183
  "provider",
164
184
  choices=["gemini", "openai"],
165
- help="Provider to authenticate with",
185
+ metavar="PROVIDER",
186
+ help="Provider to authenticate with: gemini (Google) or openai (ChatGPT Plus/Pro)",
166
187
  )
167
-
188
+
168
189
  # logout command
169
- 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
+ )
170
195
  logout_parser.add_argument(
171
196
  "provider",
172
197
  choices=["gemini", "openai"],
173
- help="Provider to logout from",
198
+ metavar="PROVIDER",
199
+ help="Provider to logout from: gemini or openai",
174
200
  )
175
-
201
+
176
202
  # status command
177
- subparsers.add_parser("status", help="Show authentication status")
178
-
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
+
179
209
  # refresh command
180
- 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
+ )
181
215
  refresh_parser.add_argument(
182
216
  "provider",
183
217
  choices=["gemini", "openai"],
184
- help="Provider to refresh token for",
218
+ metavar="PROVIDER",
219
+ help="Provider to refresh token for: gemini or openai",
185
220
  )
186
-
221
+
222
+ # init command
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
+
187
229
  args = parser.parse_args()
188
-
230
+
189
231
  if not args.command:
190
232
  parser.print_help()
191
233
  return 1
192
-
234
+
193
235
  token_store = TokenStore()
194
-
236
+
195
237
  if args.command == "login":
196
238
  return cmd_login(args.provider, token_store)
197
239
  elif args.command == "logout":
@@ -200,7 +242,10 @@ def main():
200
242
  return cmd_status(token_store)
201
243
  elif args.command == "refresh":
202
244
  return cmd_refresh(args.provider, token_store)
203
-
245
+ elif args.command == "init":
246
+ print(bootstrap_repo())
247
+ return 0
248
+
204
249
  return 0
205
250
 
206
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