stravinsky 0.1.2__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.

@@ -0,0 +1,350 @@
1
+ """
2
+ OpenAI Codex OAuth 2.0 Implementation
3
+
4
+ Implements OAuth authentication for ChatGPT Plus/Pro subscriptions.
5
+ Uses the exact same OAuth flow as opencode-openai-codex-auth.
6
+
7
+ Port from: https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/lib/auth/auth.ts
8
+ """
9
+
10
+ import base64
11
+ import hashlib
12
+ import json
13
+ import secrets
14
+ import threading
15
+ import webbrowser
16
+ from dataclasses import dataclass
17
+ from http.server import HTTPServer, BaseHTTPRequestHandler
18
+ from typing import Any
19
+ from urllib.parse import parse_qs, urlencode, urlparse
20
+ import time
21
+
22
+ import httpx
23
+
24
+
25
+ # OAuth constants (from openai/codex via opencode-openai-codex-auth)
26
+ CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
27
+ AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" # Note: /oauth/authorize
28
+ TOKEN_URL = "https://auth.openai.com/oauth/token"
29
+ REDIRECT_URI = "http://localhost:1455/auth/callback"
30
+ SCOPE = "openid profile email offline_access"
31
+
32
+ # Callback configuration
33
+ OPENAI_CALLBACK_PORT = 1455
34
+
35
+
36
+ @dataclass
37
+ class PKCEPair:
38
+ """PKCE verifier and challenge pair."""
39
+ verifier: str
40
+ challenge: str
41
+ method: str = "S256"
42
+
43
+
44
+ @dataclass
45
+ class TokenResult:
46
+ """OAuth token exchange result."""
47
+ access_token: str
48
+ refresh_token: str
49
+ expires_in: int
50
+ token_type: str
51
+
52
+
53
+ def generate_pkce_pair() -> PKCEPair:
54
+ """Generate PKCE verifier and challenge pair using S256."""
55
+ # Generate verifier (43+ chars recommended)
56
+ verifier = secrets.token_urlsafe(32)
57
+
58
+ # SHA-256 hash and base64url encode
59
+ challenge_bytes = hashlib.sha256(verifier.encode("ascii")).digest()
60
+ challenge = base64.urlsafe_b64encode(challenge_bytes).rstrip(b"=").decode("ascii")
61
+
62
+ return PKCEPair(verifier=verifier, challenge=challenge, method="S256")
63
+
64
+
65
+ def create_state() -> str:
66
+ """Generate a random state value for OAuth flow."""
67
+ return secrets.token_hex(16)
68
+
69
+
70
+ def build_auth_url(redirect_uri: str = REDIRECT_URI) -> tuple[str, str, str]:
71
+ """
72
+ Build OpenAI OAuth authorization URL with PKCE.
73
+
74
+ Exact port from opencode-openai-codex-auth createAuthorizationFlow()
75
+
76
+ Returns:
77
+ Tuple of (auth_url, pkce_verifier, state)
78
+ """
79
+ pkce = generate_pkce_pair()
80
+ state = create_state()
81
+
82
+ # Build URL exactly as opencode-openai-codex-auth does
83
+ params = {
84
+ "response_type": "code",
85
+ "client_id": CLIENT_ID,
86
+ "redirect_uri": redirect_uri,
87
+ "scope": SCOPE,
88
+ "code_challenge": pkce.challenge,
89
+ "code_challenge_method": "S256",
90
+ "state": state,
91
+ "id_token_add_organizations": "true",
92
+ "codex_cli_simplified_flow": "true",
93
+ "originator": "codex_cli_rs",
94
+ }
95
+
96
+ url = f"{AUTHORIZE_URL}?{urlencode(params)}"
97
+ return url, pkce.verifier, state
98
+
99
+
100
+ def exchange_code(
101
+ code: str,
102
+ verifier: str,
103
+ redirect_uri: str = REDIRECT_URI,
104
+ ) -> TokenResult:
105
+ """
106
+ Exchange authorization code for tokens.
107
+
108
+ Exact port from opencode-openai-codex-auth exchangeAuthorizationCode()
109
+ """
110
+ data = {
111
+ "grant_type": "authorization_code",
112
+ "client_id": CLIENT_ID,
113
+ "code": code,
114
+ "code_verifier": verifier,
115
+ "redirect_uri": redirect_uri,
116
+ }
117
+
118
+ with httpx.Client() as client:
119
+ response = client.post(
120
+ TOKEN_URL,
121
+ data=data,
122
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
123
+ )
124
+
125
+ if response.status_code != 200:
126
+ text = response.text
127
+ print(f"[openai-oauth] code->token failed: {response.status_code} {text}")
128
+ raise Exception(f"Token exchange failed: {response.status_code} - {text}")
129
+
130
+ result = response.json()
131
+
132
+ access_token = result.get("access_token")
133
+ refresh_token = result.get("refresh_token")
134
+ expires_in = result.get("expires_in")
135
+
136
+ if not access_token or not refresh_token or not isinstance(expires_in, int):
137
+ print(f"[openai-oauth] token response missing fields: {result}")
138
+ raise Exception("Token response missing required fields")
139
+
140
+ return TokenResult(
141
+ access_token=access_token,
142
+ refresh_token=refresh_token,
143
+ expires_in=expires_in,
144
+ token_type=result.get("token_type", "Bearer"),
145
+ )
146
+
147
+
148
+ def refresh_access_token(refresh_token: str) -> TokenResult:
149
+ """
150
+ Refresh access token using refresh token.
151
+
152
+ Exact port from opencode-openai-codex-auth refreshAccessToken()
153
+ """
154
+ data = {
155
+ "grant_type": "refresh_token",
156
+ "refresh_token": refresh_token,
157
+ "client_id": CLIENT_ID,
158
+ }
159
+
160
+ with httpx.Client() as client:
161
+ response = client.post(
162
+ TOKEN_URL,
163
+ data=data,
164
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
165
+ )
166
+
167
+ if response.status_code != 200:
168
+ text = response.text
169
+ print(f"[openai-oauth] Token refresh failed: {response.status_code} {text}")
170
+ raise Exception(f"Token refresh failed: {response.status_code} - {text}")
171
+
172
+ result = response.json()
173
+
174
+ access_token = result.get("access_token")
175
+ refresh_token_new = result.get("refresh_token")
176
+ expires_in = result.get("expires_in")
177
+
178
+ if not access_token or not refresh_token_new or not isinstance(expires_in, int):
179
+ print(f"[openai-oauth] Token refresh response missing fields: {result}")
180
+ raise Exception("Token refresh response missing required fields")
181
+
182
+ return TokenResult(
183
+ access_token=access_token,
184
+ refresh_token=refresh_token_new,
185
+ expires_in=expires_in,
186
+ token_type=result.get("token_type", "Bearer"),
187
+ )
188
+
189
+
190
+ # ============= Browser Flow =============
191
+
192
+ class OAuthCallbackHandler(BaseHTTPRequestHandler):
193
+ """HTTP request handler for OAuth callback."""
194
+
195
+ callback_result: dict[str, Any] = {}
196
+ server_ready = threading.Event()
197
+
198
+ def log_message(self, format, *args):
199
+ pass
200
+
201
+ def do_GET(self):
202
+ parsed = urlparse(self.path)
203
+
204
+ # Handle /auth/callback path (matching REDIRECT_URI)
205
+ if parsed.path == "/auth/callback":
206
+ params = parse_qs(parsed.query)
207
+
208
+ OAuthCallbackHandler.callback_result = {
209
+ "code": params.get("code", [""])[0],
210
+ "state": params.get("state", [""])[0],
211
+ "error": params.get("error", [None])[0],
212
+ "error_description": params.get("error_description", [""])[0],
213
+ }
214
+
215
+ if OAuthCallbackHandler.callback_result["code"]:
216
+ # Success page - styled like opencode-openai-codex-auth
217
+ body = b"""<!DOCTYPE html>
218
+ <html>
219
+ <head>
220
+ <title>Login Successful</title>
221
+ <style>
222
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
223
+ display: flex; justify-content: center; align-items: center; min-height: 100vh;
224
+ margin: 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
225
+ .card { background: white; padding: 3rem; border-radius: 16px; text-align: center;
226
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3); max-width: 400px; }
227
+ h1 { color: #10a37f; margin-bottom: 1rem; }
228
+ p { color: #666; }
229
+ </style>
230
+ </head>
231
+ <body>
232
+ <div class="card">
233
+ <h1>&#x2713; Login Successful!</h1>
234
+ <p>You can close this window and return to the terminal.</p>
235
+ </div>
236
+ </body>
237
+ </html>"""
238
+ else:
239
+ error = OAuthCallbackHandler.callback_result.get("error", "unknown")
240
+ error_desc = OAuthCallbackHandler.callback_result.get("error_description", "")
241
+ body = f"""<!DOCTYPE html>
242
+ <html>
243
+ <head><title>Login Failed</title>
244
+ <style>
245
+ body {{ font-family: -apple-system, sans-serif; display: flex; justify-content: center;
246
+ align-items: center; min-height: 100vh; margin: 0; background: #f5f5f5; }}
247
+ .card {{ background: white; padding: 3rem; border-radius: 16px; text-align: center;
248
+ box-shadow: 0 10px 40px rgba(0,0,0,0.1); max-width: 400px; }}
249
+ h1 {{ color: #ef4444; }}
250
+ </style>
251
+ </head>
252
+ <body>
253
+ <div class="card">
254
+ <h1>Login Failed</h1>
255
+ <p><strong>Error:</strong> {error}</p>
256
+ <p>{error_desc}</p>
257
+ <p>Please check the terminal for more details.</p>
258
+ </div>
259
+ </body>
260
+ </html>""".encode()
261
+
262
+ self.send_response(200)
263
+ self.send_header("Content-Type", "text/html")
264
+ self.send_header("Content-Length", str(len(body)))
265
+ self.end_headers()
266
+ self.wfile.write(body)
267
+
268
+ OAuthCallbackHandler.server_ready.set()
269
+ else:
270
+ self.send_response(404)
271
+ self.end_headers()
272
+
273
+
274
+ def perform_oauth_flow(timeout: int = 300) -> TokenResult:
275
+ """
276
+ Perform OpenAI OAuth browser flow.
277
+
278
+ Args:
279
+ timeout: Timeout in seconds
280
+
281
+ Returns:
282
+ Token result with access and refresh tokens.
283
+ """
284
+ OAuthCallbackHandler.callback_result = {}
285
+ OAuthCallbackHandler.server_ready.clear()
286
+
287
+ # Try to use the required port (1455)
288
+ try:
289
+ server = HTTPServer(("localhost", OPENAI_CALLBACK_PORT), OAuthCallbackHandler)
290
+ port = OPENAI_CALLBACK_PORT
291
+ redirect_uri = REDIRECT_URI
292
+ except OSError as e:
293
+ print(f"\n⚠️ Cannot bind to port {OPENAI_CALLBACK_PORT}: {e}")
294
+ print(" This port is required for OpenAI OAuth.")
295
+ print(" Please stop any process using this port (e.g., Codex CLI)")
296
+ print(" Or use the Codex CLI directly: codex login")
297
+ raise Exception(f"Port {OPENAI_CALLBACK_PORT} is required but unavailable")
298
+
299
+ server_thread = threading.Thread(target=lambda: server.handle_request())
300
+ server_thread.daemon = True
301
+ server_thread.start()
302
+
303
+ try:
304
+ auth_url, verifier, state = build_auth_url(redirect_uri)
305
+
306
+ print(f"\n🔐 Opening browser for OpenAI authentication...")
307
+ print(f"\nIf browser doesn't open, visit:")
308
+ print(f"{auth_url}\n")
309
+
310
+ webbrowser.open(auth_url)
311
+
312
+ if not OAuthCallbackHandler.server_ready.wait(timeout):
313
+ raise Exception("OAuth callback timeout - no response received")
314
+
315
+ result = OAuthCallbackHandler.callback_result
316
+
317
+ if result.get("error"):
318
+ error_desc = result.get("error_description", "")
319
+ raise Exception(f"OAuth error: {result['error']} - {error_desc}")
320
+
321
+ if not result.get("code"):
322
+ raise Exception("No authorization code received")
323
+
324
+ # Verify state matches
325
+ if result.get("state") != state:
326
+ print(f"[openai-oauth] State mismatch: expected {state}, got {result.get('state')}")
327
+ # Continue anyway - some OAuth servers don't return state
328
+
329
+ print("📝 Exchanging code for tokens...")
330
+ tokens = exchange_code(result["code"], verifier, redirect_uri)
331
+
332
+ print(f"✓ Successfully authenticated with OpenAI")
333
+
334
+ return tokens
335
+
336
+ finally:
337
+ server.server_close()
338
+
339
+
340
+ if __name__ == "__main__":
341
+ try:
342
+ result = perform_oauth_flow()
343
+ print(f"\nAccess Token: {result.access_token[:30]}...")
344
+ if result.refresh_token:
345
+ print(f"Refresh Token: {result.refresh_token[:20]}...")
346
+ print(f"Expires In: {result.expires_in}s")
347
+ except Exception as e:
348
+ print(f"\n✗ OAuth failed: {e}")
349
+ import sys
350
+ sys.exit(1)
@@ -0,0 +1,195 @@
1
+ """
2
+ Secure token storage using system keyring.
3
+
4
+ Stores OAuth tokens securely using the OS keyring:
5
+ - macOS: Keychain
6
+ - Linux: Secret Service (GNOME Keyring, KWallet)
7
+ - Windows: Windows Credential Locker
8
+ """
9
+
10
+ import json
11
+ import time
12
+ from typing import TypedDict
13
+
14
+ import keyring
15
+
16
+
17
+ class TokenData(TypedDict, total=False):
18
+ """OAuth token data structure."""
19
+
20
+ access_token: str
21
+ refresh_token: str
22
+ expires_at: float # Unix timestamp
23
+ token_type: str
24
+ scope: str
25
+
26
+
27
+ class TokenStore:
28
+ """
29
+ Secure storage for OAuth tokens using system keyring.
30
+
31
+ Each provider (gemini, openai) stores its tokens separately.
32
+ Tokens are serialized as JSON for storage.
33
+ """
34
+
35
+ SERVICE_NAME = "stravinsky"
36
+
37
+ def __init__(self, service_name: str | None = None):
38
+ """Initialize the token store.
39
+
40
+ Args:
41
+ service_name: Override the default service name for testing.
42
+ """
43
+ self.service_name = service_name or self.SERVICE_NAME
44
+
45
+ def _key(self, provider: str) -> str:
46
+ """Generate the keyring key for a provider."""
47
+ return f"{self.service_name}-{provider}"
48
+
49
+ def get_token(self, provider: str) -> TokenData | None:
50
+ """
51
+ Retrieve stored token for a provider.
52
+
53
+ Args:
54
+ provider: The provider name (e.g., 'gemini', 'openai')
55
+
56
+ Returns:
57
+ TokenData if found and valid, None otherwise.
58
+ """
59
+ try:
60
+ data = keyring.get_password(self.service_name, self._key(provider))
61
+ if not data:
62
+ return None
63
+ return json.loads(data)
64
+ except (json.JSONDecodeError, keyring.errors.KeyringError):
65
+ return None
66
+
67
+ def set_token(
68
+ self,
69
+ provider: str,
70
+ token: TokenData | None = None,
71
+ *,
72
+ access_token: str | None = None,
73
+ refresh_token: str | None = None,
74
+ expires_at: float | None = None,
75
+ ) -> None:
76
+ """
77
+ Store a token for a provider.
78
+
79
+ Can be called with a TokenData dict or individual parameters.
80
+
81
+ Args:
82
+ provider: The provider name (e.g., 'gemini', 'openai')
83
+ token: The token data dict to store (optional)
84
+ access_token: Access token string (optional)
85
+ refresh_token: Refresh token string (optional)
86
+ expires_at: Expiry timestamp (optional)
87
+ """
88
+ if token is not None:
89
+ data = json.dumps(token)
90
+ else:
91
+ token_data: TokenData = {}
92
+ if access_token:
93
+ token_data["access_token"] = access_token
94
+ if refresh_token:
95
+ token_data["refresh_token"] = refresh_token
96
+ if expires_at:
97
+ token_data["expires_at"] = expires_at
98
+ data = json.dumps(token_data)
99
+ keyring.set_password(self.service_name, self._key(provider), data)
100
+
101
+ def delete_token(self, provider: str) -> bool:
102
+ """
103
+ Delete stored token for a provider.
104
+
105
+ Args:
106
+ provider: The provider name (e.g., 'gemini', 'openai')
107
+
108
+ Returns:
109
+ True if deleted, False if not found.
110
+ """
111
+ try:
112
+ keyring.delete_password(self.service_name, self._key(provider))
113
+ return True
114
+ except keyring.errors.PasswordDeleteError:
115
+ return False
116
+
117
+ def has_valid_token(self, provider: str) -> bool:
118
+ """
119
+ Check if a valid (non-expired) token exists for a provider.
120
+
121
+ Args:
122
+ provider: The provider name
123
+
124
+ Returns:
125
+ True if a valid token exists.
126
+ """
127
+ token = self.get_token(provider)
128
+ if not token:
129
+ return False
130
+
131
+ # Check if token has expired
132
+ expires_at = token.get("expires_at", 0)
133
+ if expires_at > 0 and time.time() > expires_at:
134
+ return False
135
+
136
+ return "access_token" in token
137
+
138
+ def get_access_token(self, provider: str) -> str | None:
139
+ """
140
+ Get the access token for a provider, if valid.
141
+
142
+ Args:
143
+ provider: The provider name
144
+
145
+ Returns:
146
+ Access token string if valid, None otherwise.
147
+ """
148
+ token = self.get_token(provider)
149
+ if not token:
150
+ return None
151
+
152
+ # Check expiration
153
+ expires_at = token.get("expires_at", 0)
154
+ if expires_at > 0 and time.time() > expires_at:
155
+ return None
156
+
157
+ return token.get("access_token")
158
+
159
+ def needs_refresh(self, provider: str, buffer_seconds: int = 300) -> bool:
160
+ """
161
+ Check if a token needs refreshing.
162
+
163
+ Args:
164
+ provider: The provider name
165
+ buffer_seconds: Refresh this many seconds before actual expiry
166
+
167
+ Returns:
168
+ True if token needs refresh or doesn't exist.
169
+ """
170
+ token = self.get_token(provider)
171
+ if not token:
172
+ return True
173
+
174
+ expires_at = token.get("expires_at", 0)
175
+ if expires_at <= 0:
176
+ return False # No expiry set, assume valid
177
+
178
+ return time.time() > (expires_at - buffer_seconds)
179
+
180
+ def update_access_token(
181
+ self, provider: str, access_token: str, expires_in: int | None = None
182
+ ) -> None:
183
+ """
184
+ Update just the access token (after refresh).
185
+
186
+ Args:
187
+ provider: The provider name
188
+ access_token: New access token
189
+ expires_in: Seconds until expiration (optional)
190
+ """
191
+ token = self.get_token(provider) or {}
192
+ token["access_token"] = access_token
193
+ if expires_in:
194
+ token["expires_at"] = time.time() + expires_in
195
+ self.set_token(provider, token)
@@ -0,0 +1,14 @@
1
+ # Configuration module
2
+ from .hooks import (
3
+ get_hooks_config,
4
+ list_hook_scripts,
5
+ configure_hook,
6
+ get_hook_documentation,
7
+ )
8
+
9
+ __all__ = [
10
+ "get_hooks_config",
11
+ "list_hook_scripts",
12
+ "configure_hook",
13
+ "get_hook_documentation",
14
+ ]