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/client.py ADDED
@@ -0,0 +1,400 @@
1
+ """Client wrapper for alph-sdk."""
2
+
3
+ import sys
4
+ from typing import Optional, List, Dict, Any
5
+ from alph_sdk import AlphSDK
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ from rich.panel import Panel
9
+ from rich.text import Text
10
+
11
+ from .config import Config
12
+
13
+
14
+ class TunnelData:
15
+ """Custom wrapper for tunnel data with additional fields."""
16
+
17
+ def __init__(self, tunnel_data, cloudflared_token: str, jupyter_token: Optional[str] = None):
18
+ """Initialize with tunnel data and tokens."""
19
+ self.original_data = tunnel_data
20
+ self.cloudflared_token = cloudflared_token
21
+ self.jupyter_token = jupyter_token
22
+ self.project_data = None
23
+
24
+ # Proxy all attributes from original data
25
+ for attr in ['id', 'name', 'app_url', 'jupyter_url', 'hostname', 'jupyter_hostname', 'created_at']:
26
+ if hasattr(tunnel_data, attr):
27
+ setattr(self, attr, getattr(tunnel_data, attr))
28
+
29
+
30
+ class AlphAIClient:
31
+ """High-level client for interacting with the Alph API."""
32
+
33
+ def __init__(self, config: Optional[Config] = None):
34
+ """Initialize the client with configuration."""
35
+ self.config = config or Config.load()
36
+ self.console = Console()
37
+ self._sdk = None
38
+
39
+ @property
40
+ def sdk(self) -> AlphSDK:
41
+ """Get the SDK instance, creating it if necessary."""
42
+ if self._sdk is None:
43
+ if not self.config.bearer_token:
44
+ self.console.print("[red]Error: No authentication token found. Please run 'alphai login' first.[/red]")
45
+ sys.exit(1)
46
+
47
+ self._sdk = AlphSDK(**self.config.to_sdk_config())
48
+ return self._sdk
49
+
50
+ def test_connection(self) -> bool:
51
+ """Test the connection to the API."""
52
+ try:
53
+ # Try to get organizations as a connection test
54
+ response = self.sdk.orgs.get()
55
+ return response.result.status == "success" if response.result.status else True
56
+ except Exception as e:
57
+ self.console.print(f"[red]Connection test failed: {e}[/red]")
58
+ return False
59
+
60
+ def get_organizations(self) -> List[Dict[str, Any]]:
61
+ """Get all organizations."""
62
+ try:
63
+ response = self.sdk.orgs.get()
64
+ # Access organizations from response.result.organizations
65
+ return response.result.organizations or []
66
+ except Exception as e:
67
+ self.console.print(f"[red]Error getting organizations: {e}[/red]")
68
+ return []
69
+
70
+ def create_organization(self, name: str, description: Optional[str] = None) -> Optional[Dict[str, Any]]:
71
+ """Create a new organization."""
72
+ try:
73
+ response = self.sdk.orgs.create({
74
+ "name": name,
75
+ "description": description or ""
76
+ })
77
+ if response.result.status == "success":
78
+ self.console.print(f"[green]✓ Organization '{name}' created successfully[/green]")
79
+ return response.result.organization
80
+ else:
81
+ self.console.print(f"[red]Failed to create organization: {response.result.status}[/red]")
82
+ return None
83
+ except Exception as e:
84
+ self.console.print(f"[red]Error creating organization: {e}[/red]")
85
+ return None
86
+
87
+ def get_projects(self, org_id: Optional[str] = None) -> List[Dict[str, Any]]:
88
+ """Get all projects, optionally filtered by organization."""
89
+ try:
90
+ params = {}
91
+ if org_id:
92
+ params["org_id"] = org_id
93
+
94
+ response = self.sdk.projects.get(**params)
95
+ # Access projects from response.result.projects
96
+ return response.result.projects or []
97
+ except Exception as e:
98
+ self.console.print(f"[red]Error getting projects: {e}[/red]")
99
+ return []
100
+
101
+ def display_organizations(self, orgs: List[Dict[str, Any]]) -> None:
102
+ """Display organizations in a nice table format."""
103
+ if not orgs:
104
+ self.console.print("[yellow]No organizations found.[/yellow]")
105
+ return
106
+
107
+ table = Table(title="Organizations")
108
+ table.add_column("ID", style="cyan", no_wrap=True)
109
+ table.add_column("Name", style="green")
110
+ table.add_column("Role", style="blue")
111
+ table.add_column("Slug", style="dim")
112
+ table.add_column("Subscription", style="yellow")
113
+
114
+ for org in orgs:
115
+ table.add_row(
116
+ org.id or "",
117
+ org.name or "",
118
+ org.role or "",
119
+ org.slug or "",
120
+ org.subscription_level or ""
121
+ )
122
+
123
+ self.console.print(table)
124
+
125
+ def display_projects(self, projects: List[Dict[str, Any]]) -> None:
126
+ """Display projects in a nice table format."""
127
+ if not projects:
128
+ self.console.print("[yellow]No projects found.[/yellow]")
129
+ return
130
+
131
+ table = Table(title="Projects")
132
+ table.add_column("ID", style="cyan", no_wrap=True)
133
+ table.add_column("Name", style="green")
134
+ table.add_column("Organization", style="blue")
135
+ table.add_column("Status", style="yellow")
136
+ table.add_column("Created", style="dim")
137
+
138
+ for project in projects:
139
+ # Handle organization name safely
140
+ org_name = ""
141
+ if project.organization:
142
+ org_name = project.organization.name or ""
143
+
144
+ table.add_row(
145
+ project.id or "",
146
+ project.name or "",
147
+ org_name,
148
+ project.status or "",
149
+ project.created_at or ""
150
+ )
151
+
152
+ self.console.print(table)
153
+
154
+ def display_status(self) -> None:
155
+ """Display current configuration status."""
156
+ status_info = []
157
+
158
+ # API URL
159
+ status_info.append(f"[bold]API URL:[/bold] {self.config.api_url}")
160
+
161
+ # Authentication status
162
+ if self.config.bearer_token:
163
+ status_info.append("[bold]Authentication:[/bold] [green]✓ Logged in[/green]")
164
+ else:
165
+ status_info.append("[bold]Authentication:[/bold] [red]✗ Not logged in[/red]")
166
+
167
+ # Current organization
168
+ if self.config.current_org:
169
+ status_info.append(f"[bold]Current Organization:[/bold] {self.config.current_org}")
170
+ else:
171
+ status_info.append("[bold]Current Organization:[/bold] [dim]None selected[/dim]")
172
+
173
+ # Current project
174
+ if self.config.current_project:
175
+ status_info.append(f"[bold]Current Project:[/bold] {self.config.current_project}")
176
+ else:
177
+ status_info.append("[bold]Current Project:[/bold] [dim]None selected[/dim]")
178
+
179
+ # Debug mode
180
+ if self.config.debug:
181
+ status_info.append("[bold]Debug Mode:[/bold] [yellow]Enabled[/yellow]")
182
+
183
+ panel = Panel(
184
+ "\n".join(status_info),
185
+ title="alphai Status",
186
+ title_align="left"
187
+ )
188
+ self.console.print(panel)
189
+
190
+ def create_tunnel(
191
+ self,
192
+ org_slug: str,
193
+ project_name: str,
194
+ app_port: int = 5000,
195
+ jupyter_port: int = 8888
196
+ ) -> Optional[Dict[str, Any]]:
197
+ """Create a new tunnel and return the tunnel data including token."""
198
+ try:
199
+ response = self.sdk.tunnels.create(request={
200
+ "org_slug": org_slug,
201
+ "project_name": project_name,
202
+ "app_port": app_port,
203
+ "jupyter_port": jupyter_port
204
+ })
205
+
206
+ if response.result.status == "success":
207
+ tunnel_data = response.result.data
208
+ self.console.print(f"[green]✓ Tunnel created successfully[/green]")
209
+ self.console.print(f"[blue]Tunnel ID: {tunnel_data.id}[/blue]")
210
+ self.console.print(f"[blue]App URL: {tunnel_data.app_url}[/blue]")
211
+ self.console.print(f"[blue]Jupyter URL: {tunnel_data.jupyter_url}[/blue]")
212
+ return tunnel_data
213
+ else:
214
+ self.console.print(f"[red]Failed to create tunnel: {response.result.status}[/red]")
215
+ return None
216
+ except Exception as e:
217
+ self.console.print(f"[red]Error creating tunnel: {e}[/red]")
218
+ return None
219
+
220
+ def get_tunnel(self, tunnel_id: str) -> Optional[Dict[str, Any]]:
221
+ """Get tunnel information by ID."""
222
+ try:
223
+ response = self.sdk.tunnels.get(tunnel_id=tunnel_id)
224
+ return response.result.data if response.result.status == "success" else None
225
+ except Exception as e:
226
+ self.console.print(f"[red]Error getting tunnel: {e}[/red]")
227
+ return None
228
+
229
+ def delete_tunnel(self, tunnel_id: str) -> bool:
230
+ """Delete a tunnel by ID."""
231
+ try:
232
+ response = self.sdk.tunnels.delete(tunnel_id=tunnel_id)
233
+ if response.result.status == "success":
234
+ self.console.print(f"[green]✓ Tunnel {tunnel_id} deleted successfully[/green]")
235
+ return True
236
+ else:
237
+ self.console.print(f"[red]Failed to delete tunnel: {response.result.status}[/red]")
238
+ return False
239
+ except Exception as e:
240
+ self.console.print(f"[red]Error deleting tunnel: {e}[/red]")
241
+ return False
242
+
243
+ def create_project(
244
+ self,
245
+ name: str,
246
+ organization_id: str,
247
+ port: int = 5000,
248
+ url: Optional[str] = None,
249
+ port_forward_url: Optional[str] = None,
250
+ token: Optional[str] = None
251
+ ) -> Optional[Dict[str, Any]]:
252
+ """Create a new project."""
253
+ try:
254
+ response = self.sdk.projects.create(request={
255
+ "name": name,
256
+ "organization_id": organization_id,
257
+ "port": port,
258
+ "url": url,
259
+ "port_forward_url": port_forward_url,
260
+ "token": token,
261
+ "server_request": "external",
262
+ })
263
+
264
+ if response.result.status == "success":
265
+ project_data = response.result.project # Use 'project' instead of 'data'
266
+ self.console.print(f"[green]✓ Project '{name}' created successfully[/green]")
267
+ return project_data
268
+ else:
269
+ self.console.print(f"[red]Failed to create project: {response.result.status}[/red]")
270
+ return None
271
+ except Exception as e:
272
+ self.console.print(f"[red]Error creating project: {e}[/red]")
273
+ return None
274
+
275
+ def get_organization_by_slug(self, slug: str) -> Optional[Dict[str, Any]]:
276
+ """Get organization by slug."""
277
+ try:
278
+ orgs = self.get_organizations()
279
+ for org in orgs:
280
+ if hasattr(org, 'slug') and org.slug == slug:
281
+ return org
282
+ return None
283
+ except Exception as e:
284
+ self.console.print(f"[red]Error getting organization by slug: {e}[/red]")
285
+ return None
286
+
287
+ def create_tunnel_with_project(
288
+ self,
289
+ org_slug: str,
290
+ project_name: str,
291
+ app_port: int = 5000,
292
+ jupyter_port: int = 8888,
293
+ jupyter_token: Optional[str] = None
294
+ ) -> Optional[TunnelData]:
295
+ """Create a tunnel and associated project."""
296
+ # First, get the organization
297
+ org = self.get_organization_by_slug(org_slug)
298
+ if not org:
299
+ self.console.print(f"[red]Organization with slug '{org_slug}' not found[/red]")
300
+ return None
301
+
302
+ # Create the tunnel
303
+ tunnel_data = self.create_tunnel(org_slug, project_name, app_port, jupyter_port)
304
+ if not tunnel_data:
305
+ return None
306
+
307
+ # Create custom wrapper with tokens
308
+ wrapped_tunnel = TunnelData(
309
+ tunnel_data=tunnel_data,
310
+ cloudflared_token=tunnel_data.token,
311
+ jupyter_token=jupyter_token
312
+ )
313
+
314
+ # Create the associated project (with Jupyter token if available)
315
+ self.console.print(f"[yellow]Creating associated project '{project_name}'...[/yellow]")
316
+ project_data = self.create_project(
317
+ name=project_name,
318
+ organization_id=org.id,
319
+ port=app_port,
320
+ url=tunnel_data.jupyter_url,
321
+ port_forward_url=tunnel_data.app_url,
322
+ token=jupyter_token # Use Jupyter token, not cloudflared token
323
+ )
324
+
325
+ # Store project data in wrapper
326
+ wrapped_tunnel.project_data = project_data
327
+
328
+ # Enhanced logging output
329
+ self.console.print("\n[bold green]🎉 Tunnel and Project Setup Complete![/bold green]")
330
+ self.console.print(f"[bold]Tunnel ID:[/bold] {wrapped_tunnel.id}")
331
+ if project_data:
332
+ self.console.print(f"[bold]Project ID:[/bold] {project_data.id if hasattr(project_data, 'id') else 'Created'}")
333
+
334
+ # Token information
335
+ self.console.print(f"\n[bold]Cloudflared Token:[/bold]")
336
+ self.console.print(f"[dim]For tunnel setup: cloudflared service install {wrapped_tunnel.cloudflared_token}[/dim]")
337
+
338
+ if jupyter_token:
339
+ self.console.print(f"\n[bold]Jupyter Token:[/bold]")
340
+ self.console.print(f"[dim]For Jupyter access: {jupyter_token}[/dim]")
341
+
342
+ # URLs
343
+ self.console.print(f"\n[bold]Access URLs:[/bold]")
344
+ self.console.print(f"[blue]• App URL: {wrapped_tunnel.app_url}[/blue]")
345
+ self.console.print(f"[blue]• Jupyter URL: {wrapped_tunnel.jupyter_url}[/blue]")
346
+
347
+ return wrapped_tunnel
348
+
349
+ def update_project_jupyter_token(self, project_data: Dict[str, Any], jupyter_token: str) -> bool:
350
+ """Update project with Jupyter token after container starts."""
351
+ # Since there's no update method, we'll store this for the next version
352
+ # For now, just print that we have the token
353
+ self.console.print(f"[green]✓ Jupyter token extracted: {jupyter_token[:12]}...[/green]")
354
+ return True
355
+
356
+ def delete_project(self, project_id: str) -> bool:
357
+ """Delete a project by ID."""
358
+ try:
359
+ response = self.sdk.projects.delete(project_id=project_id)
360
+ if response.result.status == "success":
361
+ self.console.print(f"[green]✓ Project {project_id} deleted successfully[/green]")
362
+ return True
363
+ else:
364
+ self.console.print(f"[red]Failed to delete project: {response.result.status}[/red]")
365
+ return False
366
+ except Exception as e:
367
+ self.console.print(f"[red]Error deleting project: {e}[/red]")
368
+ return False
369
+
370
+ def cleanup_tunnel_and_project(
371
+ self,
372
+ tunnel_id: Optional[str] = None,
373
+ project_id: Optional[str] = None,
374
+ force: bool = False
375
+ ) -> bool:
376
+ """Comprehensive cleanup of tunnel and project resources."""
377
+ success = True
378
+
379
+ if not tunnel_id and not project_id:
380
+ self.console.print("[yellow]No tunnel or project ID provided for cleanup[/yellow]")
381
+ return True
382
+
383
+ # Delete tunnel first
384
+ if tunnel_id:
385
+ self.console.print(f"[yellow]Deleting tunnel {tunnel_id}...[/yellow]")
386
+ if not self.delete_tunnel(tunnel_id):
387
+ success = False
388
+
389
+ # Delete project second
390
+ if project_id:
391
+ self.console.print(f"[yellow]Deleting project {project_id}...[/yellow]")
392
+ if not self.delete_project(project_id):
393
+ success = False
394
+
395
+ if success:
396
+ self.console.print("[green]✓ Tunnel and project cleanup completed successfully[/green]")
397
+ else:
398
+ self.console.print("[yellow]⚠ Tunnel and project cleanup completed with errors[/yellow]")
399
+
400
+ return success
alphai/config.py ADDED
@@ -0,0 +1,88 @@
1
+ """Configuration management for alphai CLI."""
2
+
3
+ import os
4
+ import stat
5
+ from pathlib import Path
6
+ from typing import Optional, Dict, Any
7
+ import json
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class Config(BaseModel):
12
+ """Configuration model for alphai CLI."""
13
+
14
+ api_url: str = Field(default="https://www.runalph.ai", description="API base URL")
15
+ bearer_token: Optional[str] = Field(default=None, description="Bearer token for API authentication")
16
+ current_org: Optional[str] = Field(default=None, description="Current organization ID")
17
+ current_project: Optional[str] = Field(default=None, description="Current project ID")
18
+ debug: bool = Field(default=False, description="Enable debug mode")
19
+
20
+ @classmethod
21
+ def get_config_dir(cls) -> Path:
22
+ """Get the configuration directory."""
23
+ config_dir = Path.home() / ".alphai"
24
+ config_dir.mkdir(exist_ok=True)
25
+ # Set directory permissions to be readable/writable only by owner
26
+ config_dir.chmod(0o700)
27
+ return config_dir
28
+
29
+ @classmethod
30
+ def get_config_file(cls) -> Path:
31
+ """Get the configuration file path."""
32
+ return cls.get_config_dir() / "config.json"
33
+
34
+ @classmethod
35
+ def load(cls) -> "Config":
36
+ """Load configuration from file and environment."""
37
+ config_file = cls.get_config_file()
38
+ config_data = {}
39
+
40
+ # Load from file if it exists
41
+ if config_file.exists():
42
+ try:
43
+ with open(config_file, 'r') as f:
44
+ config_data = json.load(f)
45
+ except (json.JSONDecodeError, IOError):
46
+ pass
47
+
48
+ # Override with environment variables
49
+ if os.getenv("ALPHAI_API_URL"):
50
+ config_data["api_url"] = os.getenv("ALPHAI_API_URL")
51
+
52
+ if os.getenv("ALPHAI_DEBUG"):
53
+ config_data["debug"] = os.getenv("ALPHAI_DEBUG").lower() in ("true", "1", "yes")
54
+
55
+ # Load bearer token from environment if available (takes precedence)
56
+ if os.getenv("ALPHAI_BEARER_TOKEN"):
57
+ config_data["bearer_token"] = os.getenv("ALPHAI_BEARER_TOKEN")
58
+
59
+ return cls(**config_data)
60
+
61
+ def save(self) -> None:
62
+ """Save configuration to file with secure permissions."""
63
+ config_file = self.get_config_file()
64
+ config_data = self.model_dump()
65
+
66
+ # Write the config file
67
+ with open(config_file, 'w') as f:
68
+ json.dump(config_data, f, indent=2)
69
+
70
+ # Set file permissions to be readable/writable only by owner for security
71
+ config_file.chmod(0o600)
72
+
73
+ def set_bearer_token(self, token: str) -> None:
74
+ """Set and save bearer token securely."""
75
+ self.bearer_token = token
76
+ self.save()
77
+
78
+ def clear_bearer_token(self) -> None:
79
+ """Clear the bearer token."""
80
+ self.bearer_token = None
81
+ self.save()
82
+
83
+ def to_sdk_config(self) -> Dict[str, Any]:
84
+ """Convert to SDK configuration format."""
85
+ return {
86
+ "bearer_auth": self.bearer_token,
87
+ "server_url": self.api_url,
88
+ }