llamactl 0.3.23__tar.gz → 0.3.25__tar.gz

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 (47) hide show
  1. {llamactl-0.3.23 → llamactl-0.3.25}/PKG-INFO +7 -5
  2. {llamactl-0.3.23 → llamactl-0.3.25}/README.md +1 -1
  3. llamactl-0.3.25/pyproject.toml +50 -0
  4. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/app.py +7 -4
  5. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/auth/client.py +19 -2
  6. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/client.py +4 -1
  7. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/commands/aliased_group.py +11 -3
  8. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/commands/auth.py +105 -37
  9. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/commands/deployment.py +47 -17
  10. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/commands/dev.py +126 -11
  11. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/commands/env.py +30 -5
  12. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/commands/init.py +33 -10
  13. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/commands/pkg.py +2 -2
  14. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/commands/serve.py +21 -15
  15. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/config/_config.py +4 -4
  16. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/config/_migrations.py +7 -2
  17. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/config/auth_service.py +1 -1
  18. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/config/migrations/0001_init.sql +1 -1
  19. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/config/migrations/0002_add_auth_fields.sql +0 -2
  20. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/pkg/options.py +4 -1
  21. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/pkg/utils.py +8 -5
  22. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/textual/deployment_form.py +5 -3
  23. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/textual/deployment_help.py +8 -7
  24. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/textual/deployment_monitor.py +8 -5
  25. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/textual/git_validation.py +45 -8
  26. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/textual/github_callback_server.py +12 -12
  27. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/textual/llama_loader.py +25 -19
  28. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/textual/secrets_form.py +2 -1
  29. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/textual/styles.tcss +1 -1
  30. llamactl-0.3.25/src/llama_deploy/cli/utils/retry.py +49 -0
  31. llamactl-0.3.23/pyproject.toml +0 -48
  32. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/__init__.py +0 -0
  33. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/config/env_service.py +0 -0
  34. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/config/migrations/__init__.py +0 -0
  35. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/config/schema.py +0 -0
  36. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/debug.py +0 -0
  37. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/env.py +0 -0
  38. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/interactive_prompts/session_utils.py +0 -0
  39. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/interactive_prompts/utils.py +0 -0
  40. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/options.py +0 -0
  41. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/pkg/__init__.py +0 -0
  42. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/pkg/defaults.py +0 -0
  43. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/py.typed +0 -0
  44. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/styles.py +0 -0
  45. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/utils/env_inject.py +0 -0
  46. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/utils/redact.py +0 -0
  47. {llamactl-0.3.23 → llamactl-0.3.25}/src/llama_deploy/cli/utils/version.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: llamactl
3
- Version: 0.3.23
3
+ Version: 0.3.25
4
4
  Summary: A command-line interface for managing LlamaDeploy projects and deployments
5
5
  Author: Adrian Lyjak
6
6
  Author-email: Adrian Lyjak <adrianlyjak@gmail.com>
7
7
  License: MIT
8
- Requires-Dist: llama-deploy-core[client]>=0.3.23,<0.4.0
9
- Requires-Dist: llama-deploy-appserver>=0.3.23,<0.4.0
8
+ Requires-Dist: llama-deploy-core[client]>=0.3.25,<0.4.0
9
+ Requires-Dist: llama-deploy-appserver>=0.3.25,<0.4.0
10
10
  Requires-Dist: vibe-llama-core>=0.1.0
11
11
  Requires-Dist: rich>=13.0.0
12
12
  Requires-Dist: questionary>=2.0.0
@@ -17,7 +17,9 @@ Requires-Dist: textual>=6.0.0
17
17
  Requires-Dist: aiohttp>=3.12.14
18
18
  Requires-Dist: copier>=9.10.2
19
19
  Requires-Dist: pyjwt[crypto]>=2.10.1
20
- Requires-Python: >=3.11, <4
20
+ Requires-Dist: typing-extensions>=4.15.0
21
+ Requires-Dist: typing-extensions>=4.15.0 ; python_full_version < '3.11'
22
+ Requires-Python: >=3.10, <4
21
23
  Description-Content-Type: text/markdown
22
24
 
23
25
  # llamactl
@@ -89,7 +91,7 @@ uv add llamactl
89
91
 
90
92
  ## Configuration
91
93
 
92
- llamactl stores configuration in your home directory at `~/.llamactl/`.
94
+ llamactl stores configuration in your home directory at `~/.llamactl/`.
93
95
 
94
96
  ### Profile Configuration
95
97
  Profiles allow you to manage multiple control plane connections:
@@ -67,7 +67,7 @@ uv add llamactl
67
67
 
68
68
  ## Configuration
69
69
 
70
- llamactl stores configuration in your home directory at `~/.llamactl/`.
70
+ llamactl stores configuration in your home directory at `~/.llamactl/`.
71
71
 
72
72
  ### Profile Configuration
73
73
  Profiles allow you to manage multiple control plane connections:
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.7.20,<0.8.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [dependency-groups]
6
+ dev = [
7
+ "pytest>=8.3.4",
8
+ "pytest-asyncio>=0.25.3",
9
+ "respx>=0.22.0",
10
+ "pytest-xdist>=3.8.0",
11
+ "ty>=0.0.1a19",
12
+ "ruff>=0.12.9"
13
+ ]
14
+
15
+ [project]
16
+ name = "llamactl"
17
+ version = "0.3.25"
18
+ description = "A command-line interface for managing LlamaDeploy projects and deployments"
19
+ readme = "README.md"
20
+ license = {text = "MIT"}
21
+ authors = [
22
+ {name = "Adrian Lyjak", email = "adrianlyjak@gmail.com"}
23
+ ]
24
+ requires-python = ">=3.10, <4"
25
+ dependencies = [
26
+ "llama-deploy-core[client]>=0.3.25,<0.4.0",
27
+ "llama-deploy-appserver>=0.3.25,<0.4.0",
28
+ "vibe-llama-core>=0.1.0",
29
+ "rich>=13.0.0",
30
+ "questionary>=2.0.0",
31
+ "click>=8.2.1",
32
+ "python-dotenv>=1.0.0",
33
+ "tenacity>=9.1.2",
34
+ "textual>=6.0.0",
35
+ "aiohttp>=3.12.14",
36
+ "copier>=9.10.2",
37
+ "pyjwt[crypto]>=2.10.1",
38
+ "typing-extensions>=4.15.0",
39
+ "typing-extensions>=4.15.0 ; python_full_version < '3.11'"
40
+ ]
41
+
42
+ [project.scripts]
43
+ llamactl = "llama_deploy.cli:main"
44
+
45
+ [tool.uv.build-backend]
46
+ module-name = "llama_deploy.cli"
47
+
48
+ [tool.uv.sources]
49
+ llama-deploy-appserver = {workspace = true}
50
+ llama-deploy-core = {workspace = true}
@@ -1,9 +1,9 @@
1
1
  from importlib.metadata import PackageNotFoundError
2
2
  from importlib.metadata import version as pkg_version
3
+ from typing import Any
3
4
 
4
5
  import click
5
6
  from llama_deploy.cli.commands.aliased_group import AliasedGroup
6
- from llama_deploy.cli.config.env_service import service
7
7
  from llama_deploy.cli.options import global_options
8
8
  from rich import print as rprint
9
9
  from rich.console import Console
@@ -12,10 +12,13 @@ from rich.text import Text
12
12
  console = Console(highlight=False)
13
13
 
14
14
 
15
- def print_version(ctx: click.Context, param: click.Option, value: bool) -> None:
15
+ def print_version(ctx: click.Context, param: click.Parameter, value: Any) -> None:
16
16
  """Print the version of llama_deploy"""
17
+
18
+ from llama_deploy.cli.config.env_service import service
19
+
17
20
  if not value or ctx.resilient_parsing:
18
- return
21
+ return None
19
22
  try:
20
23
  ver = pkg_version("llamactl")
21
24
  console.print(Text.assemble("client version: ", (ver, "green")))
@@ -65,5 +68,5 @@ def print_version(ctx: click.Context, param: click.Option, value: bool) -> None:
65
68
  help="Print client and server versions of LlamaDeploy",
66
69
  )
67
70
  @global_options
68
- def app():
71
+ def app() -> None:
69
72
  pass
@@ -2,16 +2,30 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import logging
5
+ import sys
5
6
  from types import TracebackType
6
- from typing import Any, AsyncContextManager, AsyncGenerator, Awaitable, Callable, Self
7
+ from typing import (
8
+ Any,
9
+ AsyncContextManager,
10
+ AsyncGenerator,
11
+ Awaitable,
12
+ Callable,
13
+ )
7
14
 
8
15
  import httpx
9
16
  import jwt
17
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
10
18
  from jwt.algorithms import RSAAlgorithm # type: ignore[possibly-unbound-import]
11
19
  from llama_deploy.cli.config.schema import DeviceOIDC
12
20
  from llama_deploy.core.client.ssl_util import get_httpx_verify_param
13
21
  from pydantic import BaseModel
14
22
 
23
+ if sys.version_info >= (3, 11):
24
+ from typing import Self
25
+ else:
26
+ from typing_extensions import Self
27
+
28
+
15
29
  logger = logging.getLogger(__name__)
16
30
 
17
31
 
@@ -328,7 +342,10 @@ async def decode_jwt_claims(
328
342
  if key.kty != "RSA":
329
343
  raise ValueError("Unsupported JWK kty; only RSA is supported")
330
344
  key_json = key.model_dump_json()
331
- public_key = RSAAlgorithm.from_jwk(key_json)
345
+ raw_key = RSAAlgorithm.from_jwk(key_json)
346
+ if not isinstance(raw_key, RSAPublicKey):
347
+ raise ValueError("Unsupported RSA key type; expected RSAPublicKey from JWKS")
348
+ public_key = raw_key
332
349
 
333
350
  return jwt.decode(
334
351
  token,
@@ -1,12 +1,13 @@
1
1
  from contextlib import asynccontextmanager
2
2
  from typing import AsyncGenerator
3
3
 
4
- from llama_deploy.cli.config.env_service import service
5
4
  from llama_deploy.core.client.manage_client import ControlPlaneClient, ProjectClient
6
5
  from rich import print as rprint
7
6
 
8
7
 
9
8
  def get_control_plane_client() -> ControlPlaneClient:
9
+ from llama_deploy.cli.config.env_service import service
10
+
10
11
  auth_svc = service.current_auth_service()
11
12
  profile = service.current_auth_service().get_current_profile()
12
13
  if profile:
@@ -23,6 +24,8 @@ def get_control_plane_client() -> ControlPlaneClient:
23
24
 
24
25
 
25
26
  def get_project_client() -> ProjectClient:
27
+ from llama_deploy.cli.config.env_service import service
28
+
26
29
  auth_svc = service.current_auth_service()
27
30
  profile = auth_svc.get_current_profile()
28
31
  if not profile:
@@ -27,7 +27,15 @@ class AliasedGroup(click.Group):
27
27
 
28
28
  def resolve_command(
29
29
  self, ctx: click.Context, args: list[str]
30
- ) -> tuple[str | None, click.Command | None, list[str]]:
30
+ ) -> tuple[str, click.Command, list[str]]:
31
31
  # always return the full command name
32
- _, cmd, args = super().resolve_command(ctx, args)
33
- return cmd.name if cmd else None, cmd, args
32
+ cmd_name, cmd, args = super().resolve_command(ctx, args)
33
+ assert cmd is not None
34
+ full_name: str = (
35
+ cmd.name
36
+ if cmd.name
37
+ else cmd_name
38
+ if isinstance(cmd_name, str)
39
+ else "<none>" # shouldn't happen
40
+ )
41
+ return (full_name, cmd, args)
@@ -1,28 +1,15 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
4
  import json
3
5
  import os
4
6
  import platform
5
7
  import subprocess
6
8
  import sys
7
- import webbrowser
8
9
  from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any
9
11
 
10
12
  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
13
  from llama_deploy.cli.styles import (
27
14
  ACTIVE_INDICATOR,
28
15
  HEADER_COLOR,
@@ -30,19 +17,34 @@ from llama_deploy.cli.styles import (
30
17
  PRIMARY_COL,
31
18
  WARNING,
32
19
  )
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
20
  from rich import print as rprint
39
21
  from rich.table import Table
40
22
  from rich.text import Text
41
23
 
42
24
  from ..app import app, console
43
- from ..config.schema import Auth, DeviceOIDC
44
25
  from ..options import global_options, interactive_option
45
26
 
27
+ if TYPE_CHECKING:
28
+ from llama_deploy.cli.config.auth_service import AuthService
29
+ from llama_deploy.cli.config.env_service import EnvService
30
+ from llama_deploy.core.schema.projects import ProjectSummary
31
+
32
+ from ..config.schema import Auth, DeviceOIDC
33
+
34
+
35
+ _ClickPath = getattr(click, "Path")
36
+
37
+
38
+ def _get_service() -> EnvService:
39
+ """Return the EnvService instance lazily.
40
+
41
+ Imports ``service`` only when needed so CLI startup stays fast and tests
42
+ can patch ``llama_deploy.cli.config.env_service.service`` directly.
43
+ """
44
+ from llama_deploy.cli.config.env_service import service # local import on purpose
45
+
46
+ return service
47
+
46
48
 
47
49
  # Specific auth/login flow exceptions
48
50
  class NoProjectsFoundError(Exception):
@@ -78,7 +80,7 @@ def create_api_key_profile(
78
80
  ) -> None:
79
81
  """Authenticate with an API key and create a profile in the current environment."""
80
82
  try:
81
- auth_svc = service.current_auth_service()
83
+ auth_svc = _get_service().current_auth_service()
82
84
 
83
85
  # Non-interactive mode: require both api-key and project-id
84
86
  if not interactive:
@@ -144,7 +146,7 @@ def device_login() -> None:
144
146
  def list_profiles() -> None:
145
147
  """List all logged in users/tokens"""
146
148
  try:
147
- auth_svc = service.current_auth_service()
149
+ auth_svc = _get_service().current_auth_service()
148
150
  profiles = auth_svc.list_profiles()
149
151
  current = auth_svc.get_current_profile()
150
152
 
@@ -184,6 +186,9 @@ def list_profiles() -> None:
184
186
  @global_options
185
187
  def destroy_database() -> None:
186
188
  """Destroy the database"""
189
+ import questionary
190
+ from llama_deploy.cli.config._config import ConfigManager
191
+
187
192
  if not questionary.confirm(
188
193
  "Are you sure you want to destroy all of your local logins? This action cannot be undone."
189
194
  ).ask():
@@ -196,7 +201,7 @@ def destroy_database() -> None:
196
201
  @global_options
197
202
  def config_database() -> None:
198
203
  """Config the database"""
199
- path = service.config_manager().db_path
204
+ path = _get_service().config_manager().db_path
200
205
  rprint(f"[bold]{path}[/bold]")
201
206
 
202
207
 
@@ -206,7 +211,7 @@ def config_database() -> None:
206
211
  @interactive_option
207
212
  def switch_profile(name: str | None, interactive: bool) -> None:
208
213
  """Switch to a different profile"""
209
- auth_svc = service.current_auth_service()
214
+ auth_svc = _get_service().current_auth_service()
210
215
  try:
211
216
  selected_auth = _select_profile(auth_svc, name, interactive)
212
217
  if not selected_auth:
@@ -228,7 +233,7 @@ def switch_profile(name: str | None, interactive: bool) -> None:
228
233
  def delete_profile(name: str | None, interactive: bool) -> None:
229
234
  """Logout from a profile and wipe all associated data"""
230
235
  try:
231
- auth_svc = service.current_auth_service()
236
+ auth_svc = _get_service().current_auth_service()
232
237
  auth = _select_profile(auth_svc, name, interactive)
233
238
  if not auth:
234
239
  rprint(f"[{WARNING}]No profile selected[/]")
@@ -253,7 +258,9 @@ def me() -> None:
253
258
  Assumes the stored API key is a JWT (e.g., OIDC id_token).
254
259
  """
255
260
  try:
256
- auth_svc = service.current_auth_service()
261
+ from llama_deploy.cli.auth.client import decode_jwt_claims_from_device_oidc
262
+
263
+ auth_svc = _get_service().current_auth_service()
257
264
  profile = auth_svc.get_current_profile()
258
265
  if not profile or not profile.device_oidc:
259
266
  raise click.ClickException(
@@ -274,10 +281,12 @@ def me() -> None:
274
281
  @global_options
275
282
  def change_project(project_id: str | None, interactive: bool) -> None:
276
283
  """Change the active project for the current profile"""
284
+ import questionary
285
+
286
+ auth_svc = _get_service().current_auth_service()
277
287
  profile = validate_authenticated_profile(interactive)
278
288
  if project_id and profile.project_id == project_id:
279
289
  return
280
- auth_svc = service.current_auth_service()
281
290
  if project_id:
282
291
  if auth_svc.env.requires_auth:
283
292
  projects = _list_projects(auth_svc)
@@ -337,7 +346,7 @@ def change_project(project_id: str | None, interactive: bool) -> None:
337
346
  "--env-file",
338
347
  "env_file",
339
348
  default=Path(".env"),
340
- type=click.Path(dir_okay=False, resolve_path=True, path_type=Path),
349
+ type=_ClickPath(dir_okay=False, resolve_path=True, path_type=Path),
341
350
  help="Path to the .env file to write",
342
351
  )
343
352
  @interactive_option
@@ -351,7 +360,10 @@ def inject_env_vars(
351
360
  based on the current profile. Always overwrites and creates the file if missing.
352
361
  """
353
362
  try:
354
- auth_svc = service.current_auth_service()
363
+ from dotenv import set_key
364
+ from llama_deploy.cli.utils.env_inject import env_vars_from_profile
365
+
366
+ auth_svc = _get_service().current_auth_service()
355
367
  profile = auth_svc.get_current_profile()
356
368
  if not profile:
357
369
  if interactive:
@@ -401,6 +413,10 @@ async def _create_or_update_agent_api_key(auth_svc: AuthService, profile: Auth)
401
413
  """
402
414
  Mutates and updates the profile with an agent API key if it does not exist or is invalid.
403
415
  """
416
+ import httpx
417
+ from llama_deploy.cli.auth.client import PlatformAuthClient
418
+ from llama_deploy.cli.utils.retry import run_with_network_retries
419
+
404
420
  if profile.api_key is not None:
405
421
  async with PlatformAuthClient(profile.api_url, profile.api_key) as client:
406
422
  try:
@@ -415,14 +431,30 @@ async def _create_or_update_agent_api_key(auth_svc: AuthService, profile: Auth)
415
431
  if profile.api_key is None:
416
432
  async with auth_svc.profile_client(profile) as client:
417
433
  name = f"{profile.name} llamactl on {profile.device_oidc.device_name if profile.device_oidc else 'unknown'}"
418
- api_key = await client.create_agent_api_key(name)
434
+
435
+ try:
436
+ api_key = await run_with_network_retries(
437
+ lambda: client.create_agent_api_key(name)
438
+ )
439
+ except httpx.HTTPStatusError:
440
+ # Do not treat HTTP errors as transient; re-raise for normal handling.
441
+ raise
442
+ except httpx.RequestError as e:
443
+ detail = str(e) or e.__class__.__name__
444
+ raise click.ClickException(
445
+ "Network error while provisioning an API token for llamactl. "
446
+ "Your login may have succeeded, but we could not create a CLI API token. "
447
+ "Please check your internet connection and try again. "
448
+ f"Details: {detail}"
449
+ ) from e
450
+
419
451
  profile.api_key = api_key.token
420
452
  profile.api_key_id = api_key.id
421
453
  auth_svc.update_profile(profile)
422
454
 
423
455
 
424
456
  def _create_device_profile() -> Auth:
425
- auth_svc = service.current_auth_service()
457
+ auth_svc = _get_service().current_auth_service()
426
458
  if not auth_svc.env.requires_auth:
427
459
  raise click.ClickException("This environment does not support authentication")
428
460
 
@@ -440,14 +472,39 @@ def _create_device_profile() -> Auth:
440
472
  if not selected_project_id:
441
473
  # User cancelled selection despite having projects
442
474
  raise click.ClickException("No project selected")
475
+
443
476
  created = auth_svc.create_or_update_profile_from_oidc(
444
477
  selected_project_id, oidc_device
445
478
  )
446
- asyncio.run(_create_or_update_agent_api_key(auth_svc, created))
479
+
480
+ # Ensure login is atomic: if provisioning the CLI API key fails, clean up the
481
+ # partially created profile so we don't leave a "logged-in but unusable" state.
482
+ try:
483
+ asyncio.run(_create_or_update_agent_api_key(auth_svc, created))
484
+ except Exception:
485
+ try:
486
+ asyncio.run(auth_svc.delete_profile(created.name))
487
+ except Exception:
488
+ # Best-effort cleanup; original error is more important for the user.
489
+ pass
490
+ raise
491
+
447
492
  return created
448
493
 
449
494
 
450
495
  async def _run_device_authentication(base_url: str) -> DeviceOIDC:
496
+ import webbrowser
497
+
498
+ from llama_deploy.cli.auth.client import (
499
+ DeviceAuthorizationRequest,
500
+ OIDCClient,
501
+ PlatformAuthDiscoveryClient,
502
+ TokenRequestDeviceCode,
503
+ decode_jwt_claims,
504
+ )
505
+
506
+ from ..config.schema import DeviceOIDC
507
+
451
508
  device_name = _auto_device_name()
452
509
  # 1) Discover upstream and CLI client_id via client
453
510
  async with PlatformAuthDiscoveryClient(base_url) as discovery:
@@ -549,8 +606,9 @@ def validate_authenticated_profile(interactive: bool) -> Auth:
549
606
  - If environment requires_auth: run token flow inline.
550
607
  - Else: create profile without token after selecting a project.
551
608
  """
609
+ import questionary
552
610
 
553
- auth_svc = service.current_auth_service()
611
+ auth_svc = _get_service().current_auth_service()
554
612
  existing = auth_svc.get_current_profile()
555
613
  if existing:
556
614
  return existing
@@ -599,6 +657,8 @@ def validate_authenticated_profile(interactive: bool) -> Auth:
599
657
 
600
658
 
601
659
  def _prompt_for_api_key() -> str:
660
+ import questionary
661
+
602
662
  entered = questionary.password("Enter API key token to login").ask()
603
663
  if entered:
604
664
  return entered.strip()
@@ -609,7 +669,9 @@ def _list_projects(
609
669
  auth_svc: AuthService,
610
670
  api_key: str | None = None,
611
671
  ) -> list[ProjectSummary]:
612
- async def _run():
672
+ async def _run() -> list[ProjectSummary]:
673
+ from llama_deploy.core.client.manage_client import ControlPlaneClient
674
+
613
675
  profile = auth_svc.get_current_profile()
614
676
  async with ControlPlaneClient.ctx(
615
677
  auth_svc.env.api_url,
@@ -624,6 +686,8 @@ def _list_projects(
624
686
  def _prompt_validate_api_key_and_list_projects(
625
687
  auth_svc: AuthService, api_key: str
626
688
  ) -> list[ProjectSummary]:
689
+ import httpx
690
+
627
691
  try:
628
692
  return _list_projects(auth_svc, api_key)
629
693
  except httpx.HTTPStatusError as e:
@@ -645,6 +709,8 @@ def _prompt_validate_api_key_and_list_projects(
645
709
  def _select_or_enter_project(
646
710
  projects: list[ProjectSummary], requires_auth: bool
647
711
  ) -> str | None:
712
+ import questionary
713
+
648
714
  if not projects:
649
715
  return None
650
716
  # select the only authorized project if there is only one
@@ -693,13 +759,15 @@ def _select_profile(
693
759
  return None
694
760
 
695
761
  try:
762
+ import questionary
763
+
696
764
  profiles = auth_svc.list_profiles()
697
765
 
698
766
  if not profiles:
699
767
  rprint(f"[{WARNING}]No profiles found[/]")
700
768
  return None
701
769
 
702
- choices = []
770
+ choices: list[Any] = []
703
771
  current = auth_svc.get_current_profile()
704
772
 
705
773
  for profile in profiles:
@@ -9,8 +9,6 @@ git ref, reads the config, and runs your app.
9
9
  import asyncio
10
10
 
11
11
  import click
12
- import questionary
13
- from llama_deploy.cli.commands.auth import validate_authenticated_profile
14
12
  from llama_deploy.cli.styles import HEADER_COLOR, MUTED_COL, PRIMARY_COL, WARNING
15
13
  from llama_deploy.core.schema.deployments import (
16
14
  DeploymentHistoryResponse,
@@ -22,16 +20,7 @@ from rich.table import Table
22
20
  from rich.text import Text
23
21
 
24
22
  from ..app import app, console
25
- from ..client import get_project_client, project_client_context
26
- from ..interactive_prompts.session_utils import (
27
- is_interactive_session,
28
- )
29
- from ..interactive_prompts.utils import (
30
- confirm_action,
31
- )
32
23
  from ..options import global_options, interactive_option
33
- from ..textual.deployment_form import create_deployment_form, edit_deployment_form
34
- from ..textual.deployment_monitor import monitor_deployment_screen
35
24
 
36
25
 
37
26
  @app.group(
@@ -50,6 +39,10 @@ def deployments() -> None:
50
39
  @interactive_option
51
40
  def list_deployments(interactive: bool) -> None:
52
41
  """List deployments for the configured project."""
42
+ from llama_deploy.cli.commands.auth import validate_authenticated_profile
43
+
44
+ from ..client import get_project_client
45
+
53
46
  validate_authenticated_profile(interactive)
54
47
  try:
55
48
  client = get_project_client()
@@ -95,11 +88,16 @@ def list_deployments(interactive: bool) -> None:
95
88
  @interactive_option
96
89
  def get_deployment(deployment_id: str | None, interactive: bool) -> None:
97
90
  """Get details of a specific deployment"""
91
+ from llama_deploy.cli.commands.auth import validate_authenticated_profile
92
+
93
+ from ..client import get_project_client
94
+ from ..textual.deployment_monitor import monitor_deployment_screen
95
+
98
96
  validate_authenticated_profile(interactive)
99
97
  try:
100
98
  client = get_project_client()
101
99
 
102
- deployment_id = select_deployment(deployment_id)
100
+ deployment_id = select_deployment(deployment_id, interactive=interactive)
103
101
  if not deployment_id:
104
102
  rprint(f"[{WARNING}]No deployment selected[/]")
105
103
  return
@@ -144,6 +142,9 @@ def create_deployment(
144
142
  interactive: bool,
145
143
  ) -> None:
146
144
  """Interactively create a new deployment"""
145
+ from llama_deploy.cli.commands.auth import validate_authenticated_profile
146
+
147
+ from ..textual.deployment_form import create_deployment_form
147
148
 
148
149
  if not interactive:
149
150
  raise click.ClickException(
@@ -168,6 +169,13 @@ def create_deployment(
168
169
  @interactive_option
169
170
  def delete_deployment(deployment_id: str | None, interactive: bool) -> None:
170
171
  """Delete a deployment"""
172
+ from llama_deploy.cli.commands.auth import validate_authenticated_profile
173
+
174
+ from ..client import get_project_client
175
+ from ..interactive_prompts.utils import (
176
+ confirm_action,
177
+ )
178
+
171
179
  validate_authenticated_profile(interactive)
172
180
  try:
173
181
  client = get_project_client()
@@ -196,6 +204,11 @@ def delete_deployment(deployment_id: str | None, interactive: bool) -> None:
196
204
  @interactive_option
197
205
  def edit_deployment(deployment_id: str | None, interactive: bool) -> None:
198
206
  """Interactively edit a deployment"""
207
+ from llama_deploy.cli.commands.auth import validate_authenticated_profile
208
+
209
+ from ..client import get_project_client
210
+ from ..textual.deployment_form import edit_deployment_form
211
+
199
212
  validate_authenticated_profile(interactive)
200
213
  try:
201
214
  client = get_project_client()
@@ -236,9 +249,13 @@ def refresh_deployment(
236
249
  deployment_id: str | None, git_ref: str | None, interactive: bool
237
250
  ) -> None:
238
251
  """Update the deployment, pulling the latest code from it's branch"""
252
+ from llama_deploy.cli.commands.auth import validate_authenticated_profile
253
+
254
+ from ..client import get_project_client
255
+
239
256
  validate_authenticated_profile(interactive)
240
257
  try:
241
- deployment_id = select_deployment(deployment_id)
258
+ deployment_id = select_deployment(deployment_id, interactive=interactive)
242
259
  if not deployment_id:
243
260
  rprint(f"[{WARNING}]No deployment selected[/]")
244
261
  return
@@ -283,6 +300,10 @@ def refresh_deployment(
283
300
  @interactive_option
284
301
  def show_history(deployment_id: str | None, interactive: bool) -> None:
285
302
  """Show release history for a deployment."""
303
+ from llama_deploy.cli.commands.auth import validate_authenticated_profile
304
+
305
+ from ..client import project_client_context
306
+
286
307
  validate_authenticated_profile(interactive)
287
308
  try:
288
309
  deployment_id = select_deployment(deployment_id, interactive=interactive)
@@ -326,6 +347,14 @@ def show_history(deployment_id: str | None, interactive: bool) -> None:
326
347
  @interactive_option
327
348
  def rollback(deployment_id: str | None, git_sha: str | None, interactive: bool) -> None:
328
349
  """Rollback a deployment to a previous git sha."""
350
+ import questionary
351
+ from llama_deploy.cli.commands.auth import validate_authenticated_profile
352
+
353
+ from ..client import project_client_context
354
+ from ..interactive_prompts.utils import (
355
+ confirm_action,
356
+ )
357
+
329
358
  validate_authenticated_profile(interactive)
330
359
  try:
331
360
  deployment_id = select_deployment(deployment_id, interactive=interactive)
@@ -387,22 +416,23 @@ def rollback(deployment_id: str | None, git_sha: str | None, interactive: bool)
387
416
  raise click.Abort()
388
417
 
389
418
 
390
- def select_deployment(
391
- deployment_id: str | None = None, interactive: bool = is_interactive_session()
392
- ) -> str | None:
419
+ def select_deployment(deployment_id: str | None, interactive: bool) -> str | None:
393
420
  """
394
421
  Select a deployment interactively if ID not provided.
395
422
  Returns the selected deployment ID or None if cancelled.
396
423
 
397
424
  In non-interactive sessions, returns None if deployment_id is not provided.
398
425
  """
426
+ import questionary
427
+
428
+ from ..client import get_project_client
429
+
399
430
  if deployment_id:
400
431
  return deployment_id
401
432
 
402
433
  # Don't attempt interactive selection in non-interactive sessions
403
434
  if not interactive:
404
435
  return None
405
-
406
436
  client = get_project_client()
407
437
  deployments = asyncio.run(client.list_deployments())
408
438