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.
- mcp_bridge/__init__.py +1 -5
- mcp_bridge/auth/cli.py +89 -44
- mcp_bridge/auth/oauth.py +88 -63
- mcp_bridge/hooks/__init__.py +49 -0
- mcp_bridge/hooks/agent_reminder.py +61 -0
- mcp_bridge/hooks/auto_slash_command.py +186 -0
- mcp_bridge/hooks/budget_optimizer.py +38 -0
- mcp_bridge/hooks/comment_checker.py +136 -0
- mcp_bridge/hooks/compaction.py +32 -0
- mcp_bridge/hooks/context_monitor.py +58 -0
- mcp_bridge/hooks/directory_context.py +40 -0
- mcp_bridge/hooks/edit_recovery.py +41 -0
- mcp_bridge/hooks/empty_message_sanitizer.py +240 -0
- mcp_bridge/hooks/keyword_detector.py +122 -0
- mcp_bridge/hooks/manager.py +96 -0
- mcp_bridge/hooks/preemptive_compaction.py +157 -0
- mcp_bridge/hooks/session_recovery.py +186 -0
- mcp_bridge/hooks/todo_enforcer.py +75 -0
- mcp_bridge/hooks/truncator.py +19 -0
- mcp_bridge/native_hooks/context.py +38 -0
- mcp_bridge/native_hooks/edit_recovery.py +46 -0
- mcp_bridge/native_hooks/stravinsky_mode.py +109 -0
- mcp_bridge/native_hooks/truncator.py +23 -0
- mcp_bridge/prompts/delphi.py +3 -2
- mcp_bridge/prompts/dewey.py +105 -21
- mcp_bridge/prompts/stravinsky.py +452 -118
- mcp_bridge/server.py +491 -668
- mcp_bridge/server_tools.py +547 -0
- mcp_bridge/tools/__init__.py +13 -3
- mcp_bridge/tools/agent_manager.py +359 -190
- mcp_bridge/tools/continuous_loop.py +67 -0
- mcp_bridge/tools/init.py +50 -0
- mcp_bridge/tools/lsp/tools.py +15 -15
- mcp_bridge/tools/model_invoke.py +594 -48
- mcp_bridge/tools/skill_loader.py +51 -47
- mcp_bridge/tools/task_runner.py +141 -0
- mcp_bridge/tools/templates.py +175 -0
- {stravinsky-0.1.2.dist-info → stravinsky-0.2.38.dist-info}/METADATA +55 -10
- stravinsky-0.2.38.dist-info/RECORD +57 -0
- stravinsky-0.1.2.dist-info/RECORD +0 -32
- {stravinsky-0.1.2.dist-info → stravinsky-0.2.38.dist-info}/WHEEL +0 -0
- {stravinsky-0.1.2.dist-info → stravinsky-0.2.38.dist-info}/entry_points.txt +0 -0
mcp_bridge/__init__.py
CHANGED
mcp_bridge/auth/cli.py
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Authentication CLI
|
|
2
|
+
Stravinsky Authentication CLI (stravinsky-auth)
|
|
3
3
|
|
|
4
|
-
|
|
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
|
|
155
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
198
|
+
metavar="PROVIDER",
|
|
199
|
+
help="Provider to logout from: gemini or openai",
|
|
174
200
|
)
|
|
175
|
-
|
|
201
|
+
|
|
176
202
|
# status command
|
|
177
|
-
subparsers.add_parser(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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(
|
|
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
|
|