llamactl 0.2.7a1__py3-none-any.whl → 0.3.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.
Files changed (41) hide show
  1. llama_deploy/cli/__init__.py +9 -22
  2. llama_deploy/cli/app.py +69 -0
  3. llama_deploy/cli/auth/client.py +362 -0
  4. llama_deploy/cli/client.py +47 -170
  5. llama_deploy/cli/commands/aliased_group.py +33 -0
  6. llama_deploy/cli/commands/auth.py +696 -0
  7. llama_deploy/cli/commands/deployment.py +300 -0
  8. llama_deploy/cli/commands/env.py +211 -0
  9. llama_deploy/cli/commands/init.py +313 -0
  10. llama_deploy/cli/commands/serve.py +239 -0
  11. llama_deploy/cli/config/_config.py +390 -0
  12. llama_deploy/cli/config/_migrations.py +65 -0
  13. llama_deploy/cli/config/auth_service.py +130 -0
  14. llama_deploy/cli/config/env_service.py +67 -0
  15. llama_deploy/cli/config/migrations/0001_init.sql +35 -0
  16. llama_deploy/cli/config/migrations/0002_add_auth_fields.sql +24 -0
  17. llama_deploy/cli/config/migrations/__init__.py +7 -0
  18. llama_deploy/cli/config/schema.py +61 -0
  19. llama_deploy/cli/env.py +5 -3
  20. llama_deploy/cli/interactive_prompts/session_utils.py +37 -0
  21. llama_deploy/cli/interactive_prompts/utils.py +6 -72
  22. llama_deploy/cli/options.py +27 -5
  23. llama_deploy/cli/py.typed +0 -0
  24. llama_deploy/cli/styles.py +10 -0
  25. llama_deploy/cli/textual/deployment_form.py +263 -36
  26. llama_deploy/cli/textual/deployment_help.py +53 -0
  27. llama_deploy/cli/textual/deployment_monitor.py +466 -0
  28. llama_deploy/cli/textual/git_validation.py +20 -21
  29. llama_deploy/cli/textual/github_callback_server.py +17 -14
  30. llama_deploy/cli/textual/llama_loader.py +13 -1
  31. llama_deploy/cli/textual/secrets_form.py +28 -8
  32. llama_deploy/cli/textual/styles.tcss +49 -8
  33. llama_deploy/cli/utils/env_inject.py +23 -0
  34. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/METADATA +9 -6
  35. llamactl-0.3.0.dist-info/RECORD +38 -0
  36. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/WHEEL +1 -1
  37. llama_deploy/cli/commands.py +0 -549
  38. llama_deploy/cli/config.py +0 -173
  39. llama_deploy/cli/textual/profile_form.py +0 -171
  40. llamactl-0.2.7a1.dist-info/RECORD +0 -19
  41. {llamactl-0.2.7a1.dist-info → llamactl-0.3.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,696 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import platform
5
+ import subprocess
6
+ import sys
7
+ import webbrowser
8
+ from pathlib import Path
9
+
10
+ import click
11
+ import httpx
12
+ import questionary
13
+ from dotenv import set_key
14
+ from llama_deploy.cli.auth.client import (
15
+ DeviceAuthorizationRequest,
16
+ OIDCClient,
17
+ PlatformAuthClient,
18
+ PlatformAuthDiscoveryClient,
19
+ TokenRequestDeviceCode,
20
+ decode_jwt_claims,
21
+ decode_jwt_claims_from_device_oidc,
22
+ )
23
+ from llama_deploy.cli.config._config import ConfigManager
24
+ from llama_deploy.cli.config.auth_service import AuthService
25
+ from llama_deploy.cli.config.env_service import service
26
+ from llama_deploy.cli.styles import (
27
+ ACTIVE_INDICATOR,
28
+ HEADER_COLOR,
29
+ MUTED_COL,
30
+ PRIMARY_COL,
31
+ WARNING,
32
+ )
33
+ from llama_deploy.cli.utils.env_inject import env_vars_from_profile
34
+ from llama_deploy.core.client.manage_client import (
35
+ ControlPlaneClient,
36
+ )
37
+ from llama_deploy.core.schema.projects import ProjectSummary
38
+ from rich import print as rprint
39
+ from rich.table import Table
40
+ from rich.text import Text
41
+
42
+ from ..app import app, console
43
+ from ..config.schema import Auth, DeviceOIDC
44
+ from ..options import global_options, interactive_option
45
+
46
+
47
+ # Create sub-applications for organizing commands
48
+ @app.group(
49
+ help="Login to llama cloud control plane to manage deployments",
50
+ no_args_is_help=True,
51
+ )
52
+ @global_options
53
+ def auth() -> None:
54
+ """Login to llama cloud control plane"""
55
+ pass
56
+
57
+
58
+ @auth.command("token")
59
+ @global_options
60
+ @click.option(
61
+ "--project-id",
62
+ help="Project ID to use for the login when creating non-interactively",
63
+ )
64
+ @click.option(
65
+ "--api-key",
66
+ help="API key to use for the login when creating non-interactively",
67
+ )
68
+ @interactive_option
69
+ def create_api_key_profile(
70
+ project_id: str | None,
71
+ api_key: str | None,
72
+ interactive: bool,
73
+ ) -> None:
74
+ """Authenticate with an API key and create a profile in the current environment."""
75
+ try:
76
+ auth_svc = service.current_auth_service()
77
+
78
+ # Non-interactive mode: require both api-key and project-id
79
+ if not interactive:
80
+ if not api_key or not project_id:
81
+ raise click.ClickException(
82
+ "--api-key and --project-id are required in non-interactive mode"
83
+ )
84
+ created = auth_svc.create_profile_from_token(project_id, api_key)
85
+ rprint(
86
+ f"[green]Created API key profile '{created.name}' and set as current[/green]"
87
+ )
88
+ return
89
+
90
+ # Interactive mode: prompt for token (masked) and validate
91
+ token_value = api_key or _prompt_for_api_key()
92
+ projects = _prompt_validate_api_key_and_list_projects(auth_svc, token_value)
93
+
94
+ # Select or enter project ID
95
+ selected_project_id = project_id or _select_or_enter_project(
96
+ projects, auth_svc.env.requires_auth
97
+ )
98
+ if not selected_project_id:
99
+ rprint(f"[{WARNING}]No project selected[/]")
100
+ return
101
+
102
+ # Create and set profile
103
+ created = auth_svc.create_profile_from_token(selected_project_id, token_value)
104
+ rprint(
105
+ f"[green]Created API key profile '{created.name}' and set as current[/green]"
106
+ )
107
+ except Exception as e:
108
+ rprint(f"[red]Error: {e}[/red]")
109
+ raise click.Abort()
110
+
111
+
112
+ @auth.command("login")
113
+ @global_options
114
+ def device_login() -> None:
115
+ """Login via web browser"""
116
+
117
+ try:
118
+ created = _create_device_profile()
119
+ rprint(
120
+ f"[green]Created login profile '{created.name}' and set as current[/green]"
121
+ )
122
+
123
+ except Exception as e:
124
+ rprint(f"[red]Error: {e}[/red]")
125
+ raise click.Abort()
126
+
127
+
128
+ @auth.command("list")
129
+ @global_options
130
+ def list_profiles() -> None:
131
+ """List all logged in users/tokens"""
132
+ try:
133
+ auth_svc = service.current_auth_service()
134
+ profiles = auth_svc.list_profiles()
135
+ current = auth_svc.get_current_profile()
136
+
137
+ if not profiles:
138
+ rprint(f"[{WARNING}]No profiles found[/]")
139
+ if auth_svc.env.requires_auth:
140
+ rprint("Create one with: [cyan]llamactl auth login[/cyan]")
141
+ else:
142
+ rprint("Create one with: [cyan]llamactl auth token[/cyan]")
143
+ return
144
+
145
+ table = Table(show_edge=False, box=None, header_style=f"bold {HEADER_COLOR}")
146
+ table.add_column(" Name", style=PRIMARY_COL)
147
+ table.add_column("Active Project", style=MUTED_COL)
148
+
149
+ for profile in profiles:
150
+ text = Text()
151
+ if profile == current:
152
+ text.append("* ", style=ACTIVE_INDICATOR)
153
+ else:
154
+ text.append(" ")
155
+ text.append(profile.name)
156
+ active_project = profile.project_id or "-"
157
+ table.add_row(
158
+ text,
159
+ active_project,
160
+ )
161
+
162
+ console.print(table)
163
+
164
+ except Exception as e:
165
+ rprint(f"[red]Error: {e}[/red]")
166
+ raise click.Abort()
167
+
168
+
169
+ @auth.command("destroy", hidden=True)
170
+ @global_options
171
+ def destroy_database() -> None:
172
+ """Destroy the database"""
173
+ if not questionary.confirm(
174
+ "Are you sure you want to destroy all of your local logins? This action cannot be undone."
175
+ ).ask():
176
+ return
177
+ ConfigManager(init_database=False).destroy_database()
178
+ rprint("[green]Database destroyed[/green]")
179
+
180
+
181
+ @auth.command("show-db", hidden=True)
182
+ @global_options
183
+ def config_database() -> None:
184
+ """Config the database"""
185
+ path = service.config_manager().db_path
186
+ rprint(f"[bold]{path}[/bold]")
187
+
188
+
189
+ @auth.command("switch")
190
+ @global_options
191
+ @click.argument("name", required=False)
192
+ @interactive_option
193
+ def switch_profile(name: str | None, interactive: bool) -> None:
194
+ """Switch to a different profile"""
195
+ auth_svc = service.current_auth_service()
196
+ try:
197
+ selected_auth = _select_profile(auth_svc, name, interactive)
198
+ if not selected_auth:
199
+ rprint(f"[{WARNING}]No profile selected[/]")
200
+ return
201
+
202
+ auth_svc.set_current_profile(selected_auth.name)
203
+ rprint(f"[green]Switched to profile '{selected_auth.name}'[/green]")
204
+
205
+ except Exception as e:
206
+ rprint(f"[red]Error: {e}[/red]")
207
+ raise click.Abort()
208
+
209
+
210
+ @auth.command("logout")
211
+ @global_options
212
+ @click.argument("name", required=False)
213
+ @interactive_option
214
+ def delete_profile(name: str | None, interactive: bool) -> None:
215
+ """Logout from a profile and wipe all associated data"""
216
+ try:
217
+ auth_svc = service.current_auth_service()
218
+ auth = _select_profile(auth_svc, name, interactive)
219
+ if not auth:
220
+ rprint(f"[{WARNING}]No profile selected[/]")
221
+ return
222
+
223
+ if asyncio.run(auth_svc.delete_profile(auth.name)):
224
+ rprint(f"[green]Logged out from '{auth.name}'[/green]")
225
+ else:
226
+ rprint(f"[red]Profile '{auth.name}' not found[/red]")
227
+
228
+ except Exception as e:
229
+ rprint(f"[red]Error: {e}[/red]")
230
+ raise click.Abort()
231
+
232
+
233
+ # Very simple introspection: decode current token via provider JWKS
234
+ @auth.command("me", hidden=True)
235
+ @global_options
236
+ def me() -> None:
237
+ """Print JWT claims for the current profile's token using provider JWKS.
238
+
239
+ Assumes the stored API key is a JWT (e.g., OIDC id_token).
240
+ """
241
+ try:
242
+ auth_svc = service.current_auth_service()
243
+ profile = auth_svc.get_current_profile()
244
+ if not profile or not profile.device_oidc:
245
+ raise click.ClickException(
246
+ "No OIDC profile selected. Run `llamactl auth login` or switch to an existing OIDC profile."
247
+ )
248
+
249
+ claims = asyncio.run(decode_jwt_claims_from_device_oidc(profile.device_oidc))
250
+ click.echo(json.dumps(claims, indent=2, sort_keys=True))
251
+ except Exception as e:
252
+ rprint(f"[red]Error: {e}[/red]")
253
+ raise click.Abort()
254
+
255
+
256
+ # Projects commands
257
+ @auth.command("project")
258
+ @click.argument("project_id", required=False)
259
+ @interactive_option
260
+ @global_options
261
+ def change_project(project_id: str | None, interactive: bool) -> None:
262
+ """Change the active project for the current profile"""
263
+ profile = validate_authenticated_profile(interactive)
264
+ if project_id and profile.project_id == project_id:
265
+ return
266
+ auth_svc = service.current_auth_service()
267
+ if project_id:
268
+ if auth_svc.env.requires_auth:
269
+ projects = _list_projects(auth_svc)
270
+ if not next(
271
+ (project for project in projects if project.project_id == project_id),
272
+ None,
273
+ ):
274
+ raise click.ClickException(f"Project {project_id} not found")
275
+ auth_svc.set_project(profile.name, project_id)
276
+ rprint(f"Set active project to [bold green]{project_id}[/]")
277
+ return
278
+ if not interactive:
279
+ raise click.ClickException(
280
+ "No --project-id provided. Run `llamactl auth project --help` for more information."
281
+ )
282
+ try:
283
+ projects = _list_projects(auth_svc)
284
+
285
+ if not projects:
286
+ rprint(f"[{WARNING}]No projects found[/]")
287
+ return
288
+ result = questionary.select(
289
+ "Select a project",
290
+ choices=[
291
+ questionary.Choice(
292
+ title=f"{project.project_name} ({project.deployment_count} deployments)",
293
+ value=project.project_id,
294
+ )
295
+ for project in projects
296
+ ]
297
+ + (
298
+ [questionary.Choice(title="Create new project", value="__CREATE__")]
299
+ if not auth_svc.env.requires_auth
300
+ else []
301
+ ),
302
+ ).ask()
303
+ if result == "__CREATE__":
304
+ project_id = questionary.text("Enter project ID").ask()
305
+ result = project_id
306
+ if result:
307
+ selected_project = next(
308
+ (project for project in projects if project.project_id == result), None
309
+ )
310
+ name = selected_project.project_name if selected_project else result
311
+ auth_svc.set_project(profile.name, result)
312
+ rprint(f"Set active project to [bold {PRIMARY_COL}]{name}[/]")
313
+ else:
314
+ rprint(f"[{WARNING}]No project selected[/]")
315
+ except Exception as e:
316
+ rprint(f"[red]Error: {e}[/red]")
317
+ raise click.Abort()
318
+
319
+
320
+ @auth.command("inject")
321
+ @global_options
322
+ @click.option(
323
+ "--env-file",
324
+ "env_file",
325
+ default=Path(".env"),
326
+ type=click.Path(dir_okay=False, resolve_path=True, path_type=Path),
327
+ help="Path to the .env file to write",
328
+ )
329
+ @interactive_option
330
+ def inject_env_vars(
331
+ env_file: Path,
332
+ interactive: bool,
333
+ ) -> None:
334
+ """Inject auth environment variables into a .env file.
335
+
336
+ Writes LLAMA_CLOUD_API_KEY, LLAMA_CLOUD_BASE_URL, and LLAMA_DEPLOY_PROJECT_ID
337
+ based on the current profile. Always overwrites and creates the file if missing.
338
+ """
339
+ try:
340
+ auth_svc = service.current_auth_service()
341
+ profile = auth_svc.get_current_profile()
342
+ if not profile:
343
+ if interactive:
344
+ profile = validate_authenticated_profile(True)
345
+ else:
346
+ raise click.ClickException(
347
+ "No profile configured. Run `llamactl auth token` to create a profile."
348
+ )
349
+ if not profile.api_key:
350
+ raise click.ClickException(
351
+ "Current profile is unauthenticated (missing API key)"
352
+ )
353
+
354
+ vars = env_vars_from_profile(profile)
355
+ if not vars:
356
+ rprint(f"[{WARNING}]No variables to inject[/]")
357
+ return
358
+ env_file.parent.mkdir(parents=True, exist_ok=True)
359
+ for key, value in vars.items():
360
+ set_key(str(env_file), key, value)
361
+ rel = os.path.relpath(env_file, Path.cwd())
362
+ rprint(
363
+ f"[green]Wrote environment variables: {', '.join(vars.keys())} to {rel}[/green]"
364
+ )
365
+ except Exception as e:
366
+ rprint(f"[red]Error: {e}[/red]")
367
+ raise click.Abort()
368
+
369
+
370
+ def _auto_device_name() -> str:
371
+ try:
372
+ if sys.platform == "darwin": # macOS
373
+ return (
374
+ subprocess.check_output(["scutil", "--get", "ComputerName"])
375
+ .decode()
376
+ .strip()
377
+ )
378
+ elif sys.platform.startswith("win"):
379
+ return os.environ["COMPUTERNAME"]
380
+ else: # Linux / Unix
381
+ return platform.node()
382
+ except Exception:
383
+ return platform.node()
384
+
385
+
386
+ async def _create_or_update_agent_api_key(auth_svc: AuthService, profile: Auth) -> None:
387
+ """
388
+ Mutates and updates the profile with an agent API key if it does not exist or is invalid.
389
+ """
390
+ if profile.api_key is not None:
391
+ async with PlatformAuthClient(profile.api_url, profile.api_key) as client:
392
+ try:
393
+ await client.me()
394
+ except httpx.HTTPStatusError as e:
395
+ if e.response.status_code == 401:
396
+ # must have been deleted
397
+ profile.api_key = None
398
+ profile.api_key_id = None
399
+ else:
400
+ raise
401
+ if profile.api_key is None:
402
+ async with auth_svc.profile_client(profile) as client:
403
+ name = f"{profile.name} llamactl on {profile.device_oidc.device_name if profile.device_oidc else 'unknown'}"
404
+ api_key = await client.create_agent_api_key(name)
405
+ profile.api_key = api_key.token
406
+ profile.api_key_id = api_key.id
407
+ auth_svc.update_profile(profile)
408
+
409
+
410
+ def _create_device_profile() -> Auth:
411
+ auth_svc = service.current_auth_service()
412
+ if not auth_svc.env.requires_auth:
413
+ raise click.ClickException("This environment does not support authentication")
414
+
415
+ base_url = auth_svc.env.api_url.rstrip("/")
416
+
417
+ oidc_device = asyncio.run(_run_device_authentication(base_url))
418
+
419
+ # Obtain or prompt for project ID and create profile
420
+ projects = _list_projects(auth_svc, oidc_device.device_access_token)
421
+ selected_project_id = _select_or_enter_project(projects, True)
422
+ if not selected_project_id:
423
+ raise click.ClickException(
424
+ "Project is required. Does this user have access to any projects?"
425
+ )
426
+ created = auth_svc.create_or_update_profile_from_oidc(
427
+ selected_project_id, oidc_device
428
+ )
429
+ asyncio.run(_create_or_update_agent_api_key(auth_svc, created))
430
+ return created
431
+
432
+
433
+ async def _run_device_authentication(base_url: str) -> DeviceOIDC:
434
+ device_name = _auto_device_name()
435
+ # 1) Discover upstream and CLI client_id via client
436
+ async with PlatformAuthDiscoveryClient(base_url) as discovery:
437
+ disc = await discovery.oidc_discovery()
438
+ upstream = disc.discovery_url
439
+ client_ids = disc.client_ids or {}
440
+ client_ids_list = list(client_ids.values())
441
+ client_id = client_ids.get("cli") or (
442
+ client_ids_list[0] if len(client_ids_list) == 1 else None
443
+ )
444
+ if not client_id:
445
+ raise click.ClickException(
446
+ "Expected 'cli' Client ID not found from auth discovery"
447
+ )
448
+
449
+ # 2) Device flow via typed OIDC client
450
+ async with OIDCClient() as oidc:
451
+ provider = await oidc.fetch_provider_configuration(upstream)
452
+ device_endpoint = provider.device_authorization_endpoint
453
+ token_endpoint = provider.token_endpoint
454
+ if not device_endpoint or not token_endpoint:
455
+ raise click.ClickException("Device Authorization not supported by provider")
456
+
457
+ scope_value = " ".join(sorted({"openid", "profile", "email", "offline_access"}))
458
+
459
+ # 3) Start device authorization
460
+ da = await oidc.device_authorization(
461
+ device_endpoint,
462
+ DeviceAuthorizationRequest(client_id=client_id, scope=scope_value),
463
+ )
464
+
465
+ rprint(
466
+ "[bold]complete authentication by visiting the verification URI and confirming the device:[/bold]"
467
+ )
468
+ if da.verification_uri:
469
+ rprint(
470
+ f"Verification URI: {da.verification_uri} (will open in your browser if supported)"
471
+ )
472
+ if da.user_code:
473
+ rprint(f"User Code: {da.user_code} to confirm the device")
474
+ if da.verification_uri_complete:
475
+ try:
476
+ webbrowser.open(da.verification_uri_complete)
477
+ except Exception:
478
+ pass
479
+
480
+ # 4) Poll token endpoint
481
+ interval = int(da.interval or 5)
482
+ while True:
483
+ await asyncio.sleep(interval)
484
+ token = await oidc.token_with_device_code(
485
+ token_endpoint,
486
+ TokenRequestDeviceCode(
487
+ device_code=da.device_code,
488
+ client_id=client_id,
489
+ ),
490
+ )
491
+ if token.error in {"authorization_pending", "slow_down"}:
492
+ if token.error == "slow_down":
493
+ interval += 5
494
+ continue
495
+ if token.error:
496
+ raise click.ClickException(
497
+ f"Token polling failed: {token.error} {token.error_description or ''}"
498
+ )
499
+ if token.id_token:
500
+ if not token.access_token:
501
+ raise click.ClickException(
502
+ "Device flow failed: token response missing access_token"
503
+ )
504
+ claims = await decode_jwt_claims(token.id_token, provider.jwks_uri)
505
+ email = claims.get("email")
506
+ if not email:
507
+ raise click.ClickException(
508
+ "Device flow failed: email not found in token"
509
+ )
510
+ user_id = claims.get("sub") or email
511
+ return DeviceOIDC(
512
+ device_name=device_name,
513
+ email=email,
514
+ user_id=user_id,
515
+ client_id=client_id,
516
+ discovery_url=upstream,
517
+ device_access_token=token.access_token,
518
+ device_refresh_token=token.refresh_token,
519
+ device_id_token=token.id_token,
520
+ )
521
+ raise click.ClickException("Device flow failed: unexpected token response")
522
+
523
+
524
+ def validate_authenticated_profile(interactive: bool) -> Auth:
525
+ """Validate that the user is authenticated within the current environment.
526
+
527
+ - If there is a current profile, return it.
528
+ - If multiple profiles exist in the current environment, prompt to select in interactive mode.
529
+ - If none exist:
530
+ - If environment requires_auth: run token flow inline.
531
+ - Else: create profile without token after selecting a project.
532
+ """
533
+
534
+ auth_svc = service.current_auth_service()
535
+ existing = auth_svc.get_current_profile()
536
+ if existing:
537
+ return existing
538
+
539
+ if not interactive:
540
+ raise click.ClickException(
541
+ "No profile configured. Run `llamactl auth token` to create a profile."
542
+ )
543
+
544
+ # Filter profiles by current environment
545
+ env_profiles = auth_svc.list_profiles()
546
+ current_env = auth_svc.env
547
+
548
+ if len(env_profiles) > 1:
549
+ # Prompt to select
550
+ choice: Auth | None = questionary.select(
551
+ "Select profile",
552
+ choices=[questionary.Choice(title=p.name, value=p) for p in env_profiles],
553
+ ).ask()
554
+ if not choice:
555
+ raise click.ClickException("No profile selected")
556
+ auth_svc.set_current_profile(choice.name)
557
+ return choice
558
+ if len(env_profiles) == 1:
559
+ only = env_profiles[0]
560
+ auth_svc.set_current_profile(only.name)
561
+ return only
562
+
563
+ # No profiles exist for this env
564
+ if current_env.requires_auth:
565
+ # Inline token flow
566
+ created = _create_device_profile()
567
+ return created
568
+ else:
569
+ # No auth required: select project and create a default profile without token
570
+ project_id: str | None = questionary.text("Enter project ID").ask()
571
+ if not project_id:
572
+ raise click.ClickException("No project ID provided")
573
+ created = auth_svc.create_profile_from_token(project_id, None)
574
+ return created
575
+
576
+
577
+ # -----------------------------
578
+ # Helpers for token/profile flow
579
+ # -----------------------------
580
+
581
+
582
+ def _prompt_for_api_key() -> str:
583
+ entered = questionary.password("Enter API key token to login").ask()
584
+ if entered:
585
+ return entered.strip()
586
+ raise click.ClickException("No API key entered")
587
+
588
+
589
+ def _list_projects(
590
+ auth_svc: AuthService,
591
+ api_key: str | None = None,
592
+ ) -> list[ProjectSummary]:
593
+ async def _run():
594
+ profile = auth_svc.get_current_profile()
595
+ async with ControlPlaneClient.ctx(
596
+ auth_svc.env.api_url,
597
+ api_key or (profile.api_key if profile else None),
598
+ None if api_key is not None else auth_svc.auth_middleware(profile),
599
+ ) as client:
600
+ return await client.list_projects()
601
+
602
+ return asyncio.run(_run())
603
+
604
+
605
+ def _prompt_validate_api_key_and_list_projects(
606
+ auth_svc: AuthService, api_key: str
607
+ ) -> list[ProjectSummary]:
608
+ try:
609
+ return _list_projects(auth_svc, api_key)
610
+ except httpx.HTTPStatusError as e:
611
+ if e.response.status_code == 401:
612
+ rprint("[red]Invalid API key. Please try again.[/red]")
613
+ return _prompt_validate_api_key_and_list_projects(
614
+ auth_svc, _prompt_for_api_key()
615
+ )
616
+ if e.response.status_code == 403:
617
+ rprint("[red]This environment requires a valid API key.[/red]")
618
+ return _prompt_validate_api_key_and_list_projects(
619
+ auth_svc, _prompt_for_api_key()
620
+ )
621
+ raise
622
+ except Exception as e:
623
+ raise click.ClickException(f"Failed to validate API key: {e}")
624
+
625
+
626
+ def _select_or_enter_project(
627
+ projects: list[ProjectSummary], requires_auth: bool
628
+ ) -> str | None:
629
+ if not projects:
630
+ return None
631
+ # select the only authorized project if there is only one
632
+ elif len(projects) == 1 and requires_auth:
633
+ return projects[0].project_id
634
+ else:
635
+ choice = questionary.select(
636
+ "Select a project",
637
+ choices=[
638
+ questionary.Choice(
639
+ title=f"{p.project_name} ({p.deployment_count} deployments)",
640
+ value=p.project_id,
641
+ )
642
+ for p in projects
643
+ ],
644
+ ).ask()
645
+ return choice
646
+
647
+
648
+ def _token_flow_for_env(auth_service: AuthService) -> Auth:
649
+ token_value = _prompt_for_api_key()
650
+ projects = _prompt_validate_api_key_and_list_projects(auth_service, token_value)
651
+ project_id = _select_or_enter_project(projects, auth_service.env.requires_auth)
652
+ if not project_id:
653
+ raise click.ClickException("No project selected")
654
+ created = auth_service.create_profile_from_token(project_id, token_value)
655
+ return created
656
+
657
+
658
+ def _select_profile(
659
+ auth_svc: AuthService, profile_name: str | None, is_interactive: bool
660
+ ) -> Auth | None:
661
+ """
662
+ Select a profile interactively if name not provided.
663
+ Returns the selected profile name or None if cancelled.
664
+
665
+ In non-interactive sessions, returns None if profile_name is not provided.
666
+ """
667
+ if profile_name:
668
+ profile = auth_svc.get_profile(profile_name)
669
+ if profile:
670
+ return profile
671
+
672
+ # Don't attempt interactive selection in non-interactive sessions
673
+ if not is_interactive:
674
+ return None
675
+
676
+ try:
677
+ profiles = auth_svc.list_profiles()
678
+
679
+ if not profiles:
680
+ rprint(f"[{WARNING}]No profiles found[/]")
681
+ return None
682
+
683
+ choices = []
684
+ current = auth_svc.get_current_profile()
685
+
686
+ for profile in profiles:
687
+ title = f"{profile.name} ({profile.api_url})"
688
+ if profile == current:
689
+ title += " [current]"
690
+ choices.append(questionary.Choice(title=title, value=profile))
691
+
692
+ return questionary.select("Select profile:", choices=choices).ask()
693
+
694
+ except Exception as e:
695
+ rprint(f"[red]Error loading profiles: {e}[/red]")
696
+ return None