alphai 0.0.7__py3-none-any.whl → 0.1.0__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.
alphai/__init__.py CHANGED
@@ -1,6 +1,10 @@
1
- from alphai.alphai import AlphAI
1
+ """alphai - A CLI tool and Python package for the runalph.ai platform."""
2
2
 
3
+ __version__ = "0.1.0"
4
+ __author__ = "American Data Science"
5
+ __email__ = "support@americandatascience.com"
3
6
 
4
- __all__ = [
5
- "AlphAI",
6
- ]
7
+ from .client import AlphAIClient
8
+ from .config import Config
9
+
10
+ __all__ = ["AlphAIClient", "Config", "__version__"]
alphai/auth.py ADDED
@@ -0,0 +1,362 @@
1
+ """Authentication management for alphai CLI."""
2
+
3
+ import os
4
+ import sys
5
+ import socket
6
+ import webbrowser
7
+ import urllib.parse
8
+ import threading
9
+ import time
10
+ from http.server import HTTPServer, BaseHTTPRequestHandler
11
+ from typing import Optional
12
+ import httpx
13
+ import questionary
14
+ from rich.console import Console
15
+ from rich.prompt import Prompt
16
+ from rich.panel import Panel
17
+
18
+ from .config import Config
19
+
20
+
21
+ class CallbackHandler(BaseHTTPRequestHandler):
22
+ """HTTP request handler for OAuth callback."""
23
+
24
+ def do_GET(self):
25
+ """Handle GET request from OAuth callback."""
26
+ # Parse the query parameters
27
+ parsed_url = urllib.parse.urlparse(self.path)
28
+ query_params = urllib.parse.parse_qs(parsed_url.query)
29
+
30
+ # Extract token from query parameters
31
+ if 'token' in query_params:
32
+ self.server.token = query_params['token'][0]
33
+ self.send_response(302) # Redirect
34
+
35
+ # Get the API URL for redirection
36
+ api_url = getattr(self.server, 'api_url', 'https://runalph.ai')
37
+
38
+ # Try to use server-hosted success page first, fallback to direct redirect
39
+ try:
40
+ # Check if server has a success page
41
+ success_url = f"{api_url}/auth/cli/success"
42
+ with httpx.Client() as client:
43
+ response = client.head(success_url, timeout=2.0)
44
+ if response.status_code == 200:
45
+ # Server has a success page, use it
46
+ redirect_url = f"{success_url}?redirect_to={urllib.parse.quote(api_url)}"
47
+ else:
48
+ # No success page, redirect directly to dashboard
49
+ redirect_url = api_url
50
+ except:
51
+ # Network error or timeout, redirect directly to dashboard
52
+ redirect_url = api_url
53
+
54
+ self.send_header('Location', redirect_url)
55
+ self.end_headers()
56
+
57
+ elif 'error' in query_params:
58
+ self.server.error = query_params['error'][0]
59
+ self.send_response(400)
60
+ self.send_header('Content-type', 'text/html')
61
+ self.end_headers()
62
+
63
+ # Simple error message for errors
64
+ self.wfile.write(f'''
65
+ <!DOCTYPE html>
66
+ <html>
67
+ <head>
68
+ <title>Authentication Error</title>
69
+ <style>
70
+ body {{ font-family: system-ui; text-align: center; padding: 2rem; }}
71
+ .error {{ color: #ef4444; }}
72
+ </style>
73
+ </head>
74
+ <body>
75
+ <h1 class="error">Authentication Error</h1>
76
+ <p>Please return to your terminal and try again.</p>
77
+ <script>setTimeout(() => window.close(), 3000);</script>
78
+ </body>
79
+ </html>
80
+ '''.encode('utf-8'))
81
+ else:
82
+ self.send_response(400)
83
+ self.send_header('Content-type', 'text/html')
84
+ self.end_headers()
85
+ self.wfile.write(b'<html><body><h2>Invalid callback</h2></body></html>')
86
+
87
+ def log_message(self, format, *args):
88
+ """Suppress log messages."""
89
+ pass
90
+
91
+
92
+ class AuthManager:
93
+ """Manage authentication for the alphai CLI."""
94
+
95
+ def __init__(self, config: Config):
96
+ """Initialize the auth manager with configuration."""
97
+ self.config = config
98
+ self.console = Console()
99
+
100
+ def login_with_token(self, token: str) -> bool:
101
+ """Login with a provided bearer token."""
102
+ if not token.strip():
103
+ self.console.print("[red]Error: Empty token provided[/red]")
104
+ return False
105
+
106
+ # Validate the token
107
+ if self.validate_token(token):
108
+ self.config.set_bearer_token(token)
109
+ return True
110
+ else:
111
+ self.console.print("[red]Error: Invalid token[/red]")
112
+ return False
113
+
114
+ def interactive_login(self) -> bool:
115
+ """Perform interactive login."""
116
+ self.console.print(Panel(
117
+ "[bold]alphai Authentication[/bold]\n\n"
118
+ "You can get your token from: https://runalph.ai/account/tokens\n"
119
+ "Or set the ALPHAI_BEARER_TOKEN environment variable.",
120
+ title="Authentication Required",
121
+ title_align="left"
122
+ ))
123
+
124
+ # Check if token is in environment first
125
+ env_token = os.getenv("ALPHAI_BEARER_TOKEN")
126
+ if env_token:
127
+ if self.validate_token(env_token):
128
+ self.config.set_bearer_token(env_token)
129
+ self.console.print("[green]✓ Using token from environment variable[/green]")
130
+ return True
131
+ else:
132
+ self.console.print("[yellow]Warning: Invalid token in environment variable[/yellow]")
133
+
134
+ # Use questionary for arrow key menu selection
135
+ method = questionary.select(
136
+ "Choose your authentication method:",
137
+ choices=[
138
+ questionary.Choice("Browser login (recommended)", value="browser"),
139
+ questionary.Choice("Token login", value="token")
140
+ ],
141
+ style=questionary.Style([
142
+ ('question', 'bold'),
143
+ ('selected', 'fg:#00aa00 bold'),
144
+ ('pointer', 'fg:#00aa00 bold'),
145
+ ('highlighted', 'fg:#00aa00'),
146
+ ('answer', 'fg:#00aa00 bold')
147
+ ])
148
+ ).ask()
149
+
150
+ if not method: # User cancelled (Ctrl+C)
151
+ self.console.print("[yellow]Authentication cancelled[/yellow]")
152
+ return False
153
+
154
+ if method == "browser":
155
+ self.console.print("[blue]Starting browser authentication...[/blue]")
156
+ return self.browser_login()
157
+ else:
158
+ # Fallback to manual token entry
159
+ self.console.print("[blue]Manual token authentication[/blue]")
160
+ token = Prompt.ask(
161
+ "Enter your bearer token",
162
+ password=True,
163
+ show_default=False
164
+ )
165
+
166
+ if not token:
167
+ self.console.print("[red]No token provided[/red]")
168
+ return False
169
+
170
+ return self.login_with_token(token)
171
+
172
+ def browser_login(self, port: int = 8080) -> bool:
173
+ """Perform browser-based login using OAuth flow."""
174
+ # Try to find an available port
175
+ for attempt_port in range(port, port + 10):
176
+ try:
177
+ # Create HTTP server to handle callback
178
+ httpd = HTTPServer(('localhost', attempt_port), CallbackHandler)
179
+ httpd.timeout = 60 # 1 minute timeout
180
+ httpd.token = None
181
+ httpd.error = None
182
+ httpd.api_url = self.config.api_url # Pass api_url to server
183
+ break
184
+ except OSError:
185
+ continue
186
+ else:
187
+ self.console.print("[red]Error: Could not find an available port for callback[/red]")
188
+ return False
189
+
190
+ redirect_uri = f"http://localhost:{attempt_port}"
191
+
192
+ # Construct the authentication URL
193
+ auth_url = f"{self.config.api_url}/auth/cli"
194
+ auth_params = {
195
+ "redirect_uri": redirect_uri,
196
+ "response_type": "token",
197
+ "hostname": socket.gethostname() # Get the machine's hostname
198
+ }
199
+ full_auth_url = f"{auth_url}?{urllib.parse.urlencode(auth_params)}"
200
+
201
+ self.console.print(Panel(
202
+ f"[bold]Browser Authentication[/bold]\n\n"
203
+ f"Opening browser for authentication...\n"
204
+ f"If the browser doesn't open automatically, visit:\n"
205
+ f"{full_auth_url}\n\n"
206
+ f"Waiting for authentication callback on port {attempt_port}...",
207
+ title="Browser Login",
208
+ title_align="left"
209
+ ))
210
+
211
+ # Open browser
212
+ try:
213
+ webbrowser.open(full_auth_url)
214
+ except Exception as e:
215
+ self.console.print(f"[yellow]Warning: Could not open browser automatically: {e}[/yellow]")
216
+ self.console.print(f"[yellow]Please visit: {full_auth_url}[/yellow]")
217
+
218
+ # Start server in a separate thread
219
+ server_thread = threading.Thread(target=httpd.handle_request)
220
+ server_thread.daemon = True
221
+ server_thread.start()
222
+
223
+ # Wait for callback with progress indicator
224
+ start_time = time.time()
225
+ while server_thread.is_alive() and time.time() - start_time < 60:
226
+ time.sleep(0.5)
227
+ # Show a simple progress indicator
228
+ elapsed = int(time.time() - start_time)
229
+ if elapsed % 5 == 0 and elapsed > 0:
230
+ self.console.print(f"[dim]Still waiting... ({elapsed}s elapsed)[/dim]")
231
+
232
+ # Check results
233
+ if hasattr(httpd, 'token') and httpd.token:
234
+ self.console.print("[green]✓ Received authentication token[/green]")
235
+ return self.login_with_token(httpd.token)
236
+ elif hasattr(httpd, 'error') and httpd.error:
237
+ self.console.print(f"[red]Authentication error: {httpd.error}[/red]")
238
+ return False
239
+ else:
240
+ self.console.print("[red]Authentication timed out or failed[/red]")
241
+ return False
242
+
243
+ def validate_token(self, token: str) -> bool:
244
+ """Validate a bearer token by making a test API call."""
245
+ try:
246
+ with httpx.Client() as client:
247
+ headers = {
248
+ "Authorization": f"Bearer {token}",
249
+ "Content-Type": "application/json"
250
+ }
251
+
252
+ # Test the token by trying to get organizations
253
+ response = client.get(
254
+ f"{self.config.api_url}/api/orgs",
255
+ headers=headers,
256
+ timeout=10.0
257
+ )
258
+
259
+ # Check if the response is successful
260
+ if response.status_code in (200, 201):
261
+ return True
262
+ elif response.status_code == 401:
263
+ self.console.print("[red]Error: Invalid or expired token[/red]")
264
+ return False
265
+ elif response.status_code == 403:
266
+ self.console.print("[red]Error: Access forbidden - check your permissions[/red]")
267
+ return False
268
+ else:
269
+ self.console.print(f"[red]Error: API returned status {response.status_code}[/red]")
270
+ return False
271
+
272
+ except httpx.ConnectError:
273
+ self.console.print(f"[red]Error: Could not connect to API at {self.config.api_url}[/red]")
274
+ return False
275
+ except httpx.TimeoutException:
276
+ self.console.print("[red]Error: Request timed out[/red]")
277
+ return False
278
+ except Exception as e:
279
+ self.console.print(f"[red]Error validating token: {e}[/red]")
280
+ return False
281
+
282
+ def refresh_token(self) -> bool:
283
+ """Refresh the current token if possible."""
284
+ # This would be implemented if the API supports token refresh
285
+ # For now, we'll just validate the existing token
286
+ if self.config.bearer_token:
287
+ return self.validate_token(self.config.bearer_token)
288
+ return False
289
+
290
+ def get_user_info(self) -> Optional[dict]:
291
+ """Get information about the currently authenticated user."""
292
+ if not self.config.bearer_token:
293
+ return None
294
+
295
+ try:
296
+ with httpx.Client() as client:
297
+ headers = {
298
+ "Authorization": f"Bearer {self.config.bearer_token}",
299
+ "Content-Type": "application/json"
300
+ }
301
+
302
+ # Try to get user info (this endpoint may not exist in the actual API)
303
+ response = client.get(
304
+ f"{self.config.api_url}/api/user",
305
+ headers=headers,
306
+ timeout=10.0
307
+ )
308
+
309
+ if response.status_code == 200:
310
+ return response.json()
311
+ else:
312
+ return None
313
+
314
+ except Exception:
315
+ return None
316
+
317
+ def is_authenticated(self) -> bool:
318
+ """Check if user is currently authenticated with a valid token."""
319
+ if not self.config.bearer_token:
320
+ return False
321
+
322
+ # Validate the current token silently (without console output)
323
+ try:
324
+ with httpx.Client() as client:
325
+ headers = {
326
+ "Authorization": f"Bearer {self.config.bearer_token}",
327
+ "Content-Type": "application/json"
328
+ }
329
+
330
+ response = client.get(
331
+ f"{self.config.api_url}/api/orgs",
332
+ headers=headers,
333
+ timeout=10.0
334
+ )
335
+
336
+ return response.status_code in (200, 201)
337
+ except Exception:
338
+ return False
339
+
340
+ def check_existing_authentication(self) -> bool:
341
+ """Check and validate existing authentication, providing user feedback."""
342
+ if not self.config.bearer_token:
343
+ return False
344
+
345
+ self.console.print("[blue]Checking existing authentication...[/blue]")
346
+
347
+ if self.validate_token(self.config.bearer_token):
348
+ self.console.print("[green]✓ Already authenticated and token is valid[/green]")
349
+
350
+ # Try to get user info for additional context
351
+ user_info = self.get_user_info()
352
+ if user_info:
353
+ email = user_info.get('email', 'Unknown')
354
+ self.console.print(f"[green]✓ Logged in as: {email}[/green]")
355
+
356
+ return True
357
+ else:
358
+ self.console.print("[yellow]⚠ Existing token is invalid or expired[/yellow]")
359
+ # Clear the invalid token
360
+ self.config.clear_bearer_token()
361
+ self.config.save()
362
+ return False