llamactl 0.3.10__tar.gz → 0.3.12__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 (39) hide show
  1. {llamactl-0.3.10 → llamactl-0.3.12}/PKG-INFO +3 -4
  2. {llamactl-0.3.10 → llamactl-0.3.12}/pyproject.toml +3 -4
  3. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/auth/client.py +6 -2
  4. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/commands/init.py +141 -43
  5. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/commands/serve.py +19 -1
  6. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/options.py +47 -12
  7. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/textual/deployment_form.py +100 -6
  8. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/textual/deployment_help.py +6 -0
  9. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/textual/styles.tcss +112 -0
  10. llamactl-0.3.12/src/llama_deploy/cli/utils/version.py +11 -0
  11. {llamactl-0.3.10 → llamactl-0.3.12}/README.md +0 -0
  12. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/__init__.py +0 -0
  13. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/app.py +0 -0
  14. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/client.py +0 -0
  15. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/commands/aliased_group.py +0 -0
  16. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/commands/auth.py +0 -0
  17. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/commands/deployment.py +0 -0
  18. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/commands/env.py +0 -0
  19. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/config/_config.py +0 -0
  20. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/config/_migrations.py +0 -0
  21. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/config/auth_service.py +0 -0
  22. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/config/env_service.py +0 -0
  23. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/config/migrations/0001_init.sql +0 -0
  24. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/config/migrations/0002_add_auth_fields.sql +0 -0
  25. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/config/migrations/__init__.py +0 -0
  26. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/config/schema.py +0 -0
  27. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/debug.py +0 -0
  28. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/env.py +0 -0
  29. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/interactive_prompts/session_utils.py +0 -0
  30. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/interactive_prompts/utils.py +0 -0
  31. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/py.typed +0 -0
  32. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/styles.py +0 -0
  33. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/textual/deployment_monitor.py +0 -0
  34. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/textual/git_validation.py +0 -0
  35. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/textual/github_callback_server.py +0 -0
  36. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/textual/llama_loader.py +0 -0
  37. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/textual/secrets_form.py +0 -0
  38. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/utils/env_inject.py +0 -0
  39. {llamactl-0.3.10 → llamactl-0.3.12}/src/llama_deploy/cli/utils/redact.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: llamactl
3
- Version: 0.3.10
3
+ Version: 0.3.12
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.10,<0.4.0
9
- Requires-Dist: llama-deploy-appserver>=0.3.10,<0.4.0
8
+ Requires-Dist: llama-deploy-core[client]>=0.3.12,<0.4.0
9
+ Requires-Dist: llama-deploy-appserver>=0.3.12,<0.4.0
10
10
  Requires-Dist: httpx>=0.24.0,<1.0.0
11
11
  Requires-Dist: rich>=13.0.0
12
12
  Requires-Dist: questionary>=2.0.0
@@ -17,7 +17,6 @@ Requires-Dist: textual>=6.0.0
17
17
  Requires-Dist: aiohttp>=3.12.14
18
18
  Requires-Dist: copier>=9.9.0
19
19
  Requires-Dist: pyjwt[crypto]>=2.10.1
20
- Requires-Dist: vibe-llama>=0.4.4,<0.5.0
21
20
  Requires-Python: >=3.11, <4
22
21
  Description-Content-Type: text/markdown
23
22
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "llamactl"
3
- version = "0.3.10"
3
+ version = "0.3.12"
4
4
  description = "A command-line interface for managing LlamaDeploy projects and deployments"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -9,8 +9,8 @@ authors = [
9
9
  ]
10
10
  requires-python = ">=3.11, <4"
11
11
  dependencies = [
12
- "llama-deploy-core[client]>=0.3.10,<0.4.0",
13
- "llama-deploy-appserver>=0.3.10,<0.4.0",
12
+ "llama-deploy-core[client]>=0.3.12,<0.4.0",
13
+ "llama-deploy-appserver>=0.3.12,<0.4.0",
14
14
  "httpx>=0.24.0,<1.0.0",
15
15
  "rich>=13.0.0",
16
16
  "questionary>=2.0.0",
@@ -21,7 +21,6 @@ dependencies = [
21
21
  "aiohttp>=3.12.14",
22
22
  "copier>=9.9.0",
23
23
  "pyjwt[crypto]>=2.10.1",
24
- "vibe-llama>=0.4.4,<0.5.0",
25
24
  ]
26
25
 
27
26
  [project.scripts]
@@ -9,6 +9,7 @@ import httpx
9
9
  import jwt
10
10
  from jwt.algorithms import RSAAlgorithm # type: ignore[possibly-unbound-import]
11
11
  from llama_deploy.cli.config.schema import DeviceOIDC
12
+ from llama_deploy.core.client.ssl_util import get_httpx_verify_param
12
13
  from pydantic import BaseModel
13
14
 
14
15
  logger = logging.getLogger(__name__)
@@ -57,10 +58,13 @@ class AuthMeResponse(BaseModel):
57
58
  class ClientContextManager(AsyncContextManager):
58
59
  def __init__(self, base_url: str | None, auth: httpx.Auth | None = None) -> None:
59
60
  self.base_url = base_url.rstrip("/") if base_url else None
61
+ verify = get_httpx_verify_param()
60
62
  if self.base_url:
61
- self.client = httpx.AsyncClient(base_url=self.base_url, auth=auth)
63
+ self.client = httpx.AsyncClient(
64
+ base_url=self.base_url, auth=auth, verify=verify
65
+ )
62
66
  else:
63
- self.client = httpx.AsyncClient(auth=auth)
67
+ self.client = httpx.AsyncClient(auth=auth, verify=verify)
64
68
 
65
69
  async def close(self) -> None:
66
70
  try:
@@ -9,12 +9,18 @@ from pathlib import Path
9
9
 
10
10
  import click
11
11
  import copier
12
+ import httpx
12
13
  import questionary
13
14
  from click.exceptions import Exit
14
15
  from llama_deploy.cli.app import app
15
- from llama_deploy.cli.options import global_options
16
+ from llama_deploy.cli.options import (
17
+ global_options,
18
+ interactive_option,
19
+ )
16
20
  from llama_deploy.cli.styles import HEADER_COLOR_HEX
21
+ from llama_deploy.core.client.ssl_util import get_httpx_verify_param
17
22
  from rich import print as rprint
23
+ from rich.text import Text
18
24
 
19
25
 
20
26
  @app.command()
@@ -40,23 +46,24 @@ from rich import print as rprint
40
46
  help="Force overwrite the directory if it exists",
41
47
  )
42
48
  @global_options
49
+ @interactive_option
43
50
  def init(
44
51
  update: bool,
45
52
  template: str | None,
46
53
  dir: Path | None,
47
54
  force: bool,
55
+ interactive: bool,
48
56
  ) -> None:
49
57
  """Create a new app repository from a template"""
50
58
  if update:
51
59
  _update()
52
60
  else:
53
- _create(template, dir, force)
54
-
61
+ _create(template, dir, force, interactive)
55
62
 
56
- def _create(template: str | None, dir: Path | None, force: bool) -> None:
57
- # defer loading to improve cli startup time
58
- from vibe_llama.sdk import VibeLlamaStarter
59
63
 
64
+ def _create(
65
+ template: str | None, dir: Path | None, force: bool, interactive: bool
66
+ ) -> None:
60
67
  @dataclass
61
68
  class TemplateOption:
62
69
  id: str
@@ -79,6 +86,15 @@ def _create(template: str | None, dir: Path | None, force: bool) -> None:
79
86
  ),
80
87
  llama_cloud=False,
81
88
  ),
89
+ TemplateOption(
90
+ id="showcase",
91
+ name="Showcase",
92
+ description="A collection of workflow and UI patterns to build LlamaDeploy apps",
93
+ source=GithubTemplateRepo(
94
+ url="https://github.com/run-llama/template-workflow-showcase"
95
+ ),
96
+ llama_cloud=False,
97
+ ),
82
98
  TemplateOption(
83
99
  id="document-qa",
84
100
  name="Document Question & Answer",
@@ -156,7 +172,23 @@ def _create(template: str | None, dir: Path | None, force: bool) -> None:
156
172
  ),
157
173
  ]
158
174
 
159
- if template is None:
175
+ # Initialize git repository if git is available
176
+ has_git = False
177
+ git_initialized = False
178
+ try:
179
+ subprocess.run(["git", "--version"], check=True, capture_output=True)
180
+ has_git = True
181
+ except (subprocess.CalledProcessError, FileNotFoundError):
182
+ # git is not available or broken; continue without git
183
+ has_git = False
184
+
185
+ if not has_git:
186
+ rprint(
187
+ "git is required to initialize a template. Make sure you have it installed and available in your PATH."
188
+ )
189
+ raise Exit(1)
190
+
191
+ if template is None and interactive:
160
192
  rprint(
161
193
  "[bold]Select a template to start from.[/bold] Either with javascript frontend UI, or just a python workflow that can be used as an API."
162
194
  )
@@ -182,16 +214,26 @@ def _create(template: str | None, dir: Path | None, force: bool) -> None:
182
214
  ),
183
215
  ).ask()
184
216
  if template is None:
185
- rprint("No template selected")
217
+ options = [o.id for o in ui_options + headless_options]
218
+ rprint(
219
+ Text(
220
+ f"No template selected. Select a template or pass a template name with --template <{'|'.join(options)}>"
221
+ )
222
+ )
186
223
  raise Exit(1)
187
224
  if dir is None:
188
- dir_str = questionary.text(
189
- "Enter the directory to create the new app in", default=template
190
- ).ask()
191
- if not dir_str:
192
- rprint("No directory provided")
193
- raise Exit(1)
194
- dir = Path(dir_str)
225
+ if interactive:
226
+ dir_str = questionary.text(
227
+ "Enter the directory to create the new app in", default=template
228
+ ).ask()
229
+ if dir_str:
230
+ dir = Path(dir_str)
231
+ else:
232
+ return
233
+ else:
234
+ rprint(f"[yellow]No directory provided. Defaulting to {template}[/]")
235
+ dir = Path(template)
236
+
195
237
  resolved_template: TemplateOption | None = next(
196
238
  (o for o in ui_options + headless_options if o.id == template), None
197
239
  )
@@ -199,48 +241,44 @@ def _create(template: str | None, dir: Path | None, force: bool) -> None:
199
241
  rprint(f"Template {template} not found")
200
242
  raise Exit(1)
201
243
  if dir.exists():
202
- is_ok = (
203
- force
204
- or questionary.confirm("Directory exists. Overwrite?", default=False).ask()
244
+ is_ok = force or (
245
+ interactive
246
+ and questionary.confirm("Directory exists. Overwrite?", default=False).ask()
205
247
  )
248
+
206
249
  if not is_ok:
250
+ rprint(
251
+ f"[yellow]Try again with another directory or pass --force to overwrite the existing directory '{str(dir)}'[/]"
252
+ )
207
253
  raise Exit(1)
208
254
  else:
209
255
  shutil.rmtree(dir, ignore_errors=True)
210
256
 
211
257
  copier.run_copy(
212
- resolved_template.source.url,
213
- dir,
214
- quiet=True,
258
+ resolved_template.source.url, dir, quiet=True, defaults=not interactive
215
259
  )
216
- # Initialize git repository if git is available
217
- has_git = False
218
- try:
219
- subprocess.run(["git", "--version"], check=True, capture_output=True)
220
- has_git = True
221
- except (subprocess.CalledProcessError, FileNotFoundError):
222
- # git is not available or broken; continue without git
223
- has_git = False
224
260
 
225
261
  # Change to the new directory and initialize git repo
226
262
  original_cwd = Path.cwd()
227
263
  os.chdir(dir)
228
264
 
229
265
  try:
230
- # Dump in a bunch of docs for AI agents
231
-
232
- vibe_llama_starter = VibeLlamaStarter(
233
- agents=["OpenAI Codex CLI"], # AGENTS.md, supported by Cursor,
234
- services=["LlamaDeploy", "LlamaIndex", "llama-index-workflows"]
235
- + (["LlamaCloud Services"] if resolved_template.llama_cloud else []),
266
+ # Dump in a bunch of docs for AI agents (best-effort)
267
+ docs_downloaded = asyncio.run(
268
+ _download_and_write_agents_md(
269
+ include_llama_cloud=resolved_template.llama_cloud
270
+ )
236
271
  )
237
- asyncio.run(vibe_llama_starter.write_instructions(overwrite=True))
238
272
  # Create symlink for Claude.md to point to AGENTS.md
239
- for alternate in ["CLAUDE.md", "GEMINI.md"]: # don't support AGENTS.md (yet?)
240
- claude_path = Path(alternate) # not supported yet
241
- agents_path = Path("AGENTS.md")
242
- if agents_path.exists() and not claude_path.exists():
243
- claude_path.symlink_to("AGENTS.md")
273
+ if docs_downloaded:
274
+ for alternate in [
275
+ "CLAUDE.md",
276
+ "GEMINI.md",
277
+ ]: # don't support AGENTS.md (yet?)
278
+ claude_path = Path(alternate) # not supported yet
279
+ agents_path = Path("AGENTS.md")
280
+ if agents_path.exists() and not claude_path.exists():
281
+ claude_path.symlink_to("AGENTS.md")
244
282
 
245
283
  # Initialize a git repo (best-effort). If anything fails, show a friendly note and continue.
246
284
  if has_git:
@@ -252,6 +290,7 @@ def _create(template: str | None, dir: Path | None, force: bool) -> None:
252
290
  check=True,
253
291
  capture_output=True,
254
292
  )
293
+ git_initialized = True
255
294
  except (subprocess.CalledProcessError, FileNotFoundError) as e:
256
295
  # Extract a short error message if present
257
296
  err_msg = ""
@@ -295,7 +334,8 @@ def _create(template: str | None, dir: Path | None, force: bool) -> None:
295
334
  rprint(" [orange3]uvx[/] llamactl serve")
296
335
  rprint("")
297
336
  rprint("[bold]To deploy:[/]")
298
- if has_git:
337
+ # Only show manual git init steps if repository failed to initialize earlier
338
+ if not git_initialized:
299
339
  rprint(" [orange3]git[/] init")
300
340
  rprint(" [orange3]git[/] add .")
301
341
  rprint(" [orange3]git[/] commit -m 'Initial commit'")
@@ -306,7 +346,7 @@ def _create(template: str | None, dir: Path | None, force: bool) -> None:
306
346
  rprint(" [orange3]git[/] push -u origin main")
307
347
  rprint("")
308
348
  # rprint(" [orange3]uvx[/] llamactl login")
309
- rprint(" [orange3]uvx[/] llamactl deploy")
349
+ rprint(" [orange3]uvx[/] llamactl deploy create")
310
350
  rprint("")
311
351
 
312
352
 
@@ -357,3 +397,61 @@ def _update():
357
397
  except (subprocess.CalledProcessError, FileNotFoundError):
358
398
  # Git not available or not in a git repo - continue silently
359
399
  pass
400
+
401
+
402
+ async def _download_and_write_agents_md(include_llama_cloud: bool) -> bool:
403
+ """Fetch a small set of reference docs and write AGENTS.md.
404
+
405
+ Replaces the previous vibe-llama usage with direct HTTP downloads.
406
+
407
+ Returns True if any documentation was fetched, False otherwise.
408
+ """
409
+ BASE_URL = "https://raw.githubusercontent.com/run-llama/vibe-llama/main"
410
+
411
+ services: dict[str, str] = {
412
+ "LlamaIndex": f"{BASE_URL}/documentation/llamaindex.md",
413
+ "LlamaCloud Services": f"{BASE_URL}/documentation/llamacloud.md",
414
+ "llama-index-workflows": f"{BASE_URL}/documentation/llama-index-workflows.md",
415
+ "LlamaDeploy": f"{BASE_URL}/documentation/llamadeploy.md",
416
+ }
417
+
418
+ selected_services: list[str] = [
419
+ "LlamaDeploy",
420
+ "LlamaIndex",
421
+ "llama-index-workflows",
422
+ ]
423
+ if include_llama_cloud:
424
+ selected_services.append("LlamaCloud Services")
425
+
426
+ urls: list[str] = [(s, u) for s in selected_services if (u := services.get(s))]
427
+
428
+ contents: list[str] = []
429
+ timeout = httpx.Timeout(5.0)
430
+ async with httpx.AsyncClient(
431
+ timeout=timeout, verify=get_httpx_verify_param()
432
+ ) as client:
433
+
434
+ async def get_docs(service: str, url: str) -> str | None:
435
+ try:
436
+ resp = await client.get(url)
437
+ resp.raise_for_status()
438
+ text = resp.text.strip()
439
+ if text:
440
+ return text
441
+ except Exception:
442
+ # best-effort: skip failures
443
+ rprint(
444
+ f"[yellow]Failed to fetch documentation for {service}, skipping[/]"
445
+ )
446
+ return None
447
+
448
+ results = await asyncio.gather(
449
+ *[get_docs(service, url) for service, url in urls]
450
+ )
451
+ contents = [r for r in results if r is not None]
452
+
453
+ if contents:
454
+ agents_md = "\n\n---\n\n".join(contents) + "\n"
455
+ Path("AGENTS.md").write_text(agents_md, encoding="utf-8")
456
+
457
+ return bool(contents)
@@ -10,7 +10,7 @@ from click.exceptions import Abort, Exit
10
10
  from llama_deploy.cli.commands.auth import validate_authenticated_profile
11
11
  from llama_deploy.cli.config.env_service import service
12
12
  from llama_deploy.cli.config.schema import Auth
13
- from llama_deploy.cli.options import interactive_option
13
+ from llama_deploy.cli.options import global_options, interactive_option
14
14
  from llama_deploy.cli.styles import WARNING
15
15
  from llama_deploy.cli.utils.redact import redact_api_key
16
16
  from llama_deploy.core.client.manage_client import ControlPlaneClient
@@ -72,7 +72,13 @@ logger = logging.getLogger(__name__)
72
72
  type=click.Path(dir_okay=True, resolve_path=True, path_type=Path),
73
73
  help="The path to the sqlite database to use for the workflow server if using local persistence",
74
74
  )
75
+ @click.option(
76
+ "--host",
77
+ type=str,
78
+ help="The host to run the API server on. Default is 127.0.0.1. Use 0.0.0.0 to allow remote access.",
79
+ )
75
80
  @interactive_option
81
+ @global_options
76
82
  def serve(
77
83
  deployment_file: Path,
78
84
  no_install: bool,
@@ -85,6 +91,7 @@ def serve(
85
91
  log_format: str | None = None,
86
92
  persistence: Literal["memory", "local", "cloud"] | None = None,
87
93
  local_persistence_path: Path | None = None,
94
+ host: str | None = None,
88
95
  interactive: bool = False,
89
96
  ) -> None:
90
97
  """Run llama_deploy API Server in the foreground. Reads the deployment configuration from the current directory. Can optionally specify a deployment file path."""
@@ -92,6 +99,16 @@ def serve(
92
99
  rprint(f"[red]Deployment file '{deployment_file}' not found[/red]")
93
100
  raise click.Abort()
94
101
 
102
+ # Early check: appserver requires a pyproject.toml in the config directory
103
+ config_dir = deployment_file if deployment_file.is_dir() else deployment_file.parent
104
+ if not (config_dir / "pyproject.toml").exists():
105
+ rprint(
106
+ "[red]No pyproject.toml found at[/red] "
107
+ f"[bold]{config_dir}[/bold].\n"
108
+ "Add a pyproject.toml to your project and re-run 'llamactl serve'."
109
+ )
110
+ raise click.Abort()
111
+
95
112
  try:
96
113
  # Pre-check: if the template requires llama cloud access, ensure credentials
97
114
  _maybe_inject_llama_cloud_credentials(
@@ -131,6 +148,7 @@ def serve(
131
148
  cloud_persistence_name=f"_public:serve_workflows_{deployment_config.name}"
132
149
  if persistence == "cloud"
133
150
  else None,
151
+ host=host,
134
152
  )
135
153
 
136
154
  except (Exit, Abort):
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import os
2
3
  from typing import Callable, ParamSpec, TypeVar
3
4
 
4
5
  import click
@@ -13,6 +14,52 @@ R = TypeVar("R")
13
14
  def global_options(f: Callable[P, R]) -> Callable[P, R]:
14
15
  """Common decorator to add global options to command groups"""
15
16
 
17
+ return native_tls_option(file_logging(f))
18
+
19
+
20
+ def interactive_option(f: Callable[P, R]) -> Callable[P, R]:
21
+ """Add an interactive option to the command"""
22
+
23
+ default = is_interactive_session()
24
+ return click.option(
25
+ "--interactive/--no-interactive",
26
+ help="Run in interactive mode. If not provided, will default to the current session's interactive state.",
27
+ is_flag=True,
28
+ default=default,
29
+ )(f)
30
+
31
+
32
+ def native_tls_option(f: Callable[P, R]) -> Callable[P, R]:
33
+ """Enable native TLS to trust system configured trust store rather than python bundled trust stores.
34
+
35
+ When enabled, we set:
36
+ - UV_NATIVE_TLS=1 to instruct uv to use the platform trust store
37
+ - LLAMA_DEPLOY_USE_TRUSTSTORE=1 to use system certificate store for Python httpx clients
38
+ """
39
+
40
+ def _enable_native_tls(
41
+ ctx: click.Context, param: click.Parameter, value: bool
42
+ ) -> bool:
43
+ if value:
44
+ # Don't override if user explicitly set a value
45
+ os.environ.setdefault("UV_NATIVE_TLS", "1")
46
+ os.environ.setdefault("LLAMA_DEPLOY_USE_TRUSTSTORE", "1")
47
+ return value
48
+
49
+ return click.option(
50
+ "--native-tls",
51
+ is_flag=True,
52
+ help=(
53
+ "Enable native TLS mode to use system certificate store rather than runtime defaults. Can be set via LLAMACTL_NATIVE_TLS=1"
54
+ ),
55
+ callback=_enable_native_tls,
56
+ expose_value=False,
57
+ is_eager=True,
58
+ envvar=["LLAMACTL_NATIVE_TLS"],
59
+ )(f)
60
+
61
+
62
+ def file_logging(f: Callable[P, R]) -> Callable[P, R]:
16
63
  def debug_callback(ctx: click.Context, param: click.Parameter, value: str) -> str:
17
64
  if value:
18
65
  setup_file_logging(level=logging._nameToLevel.get(value, logging.INFO))
@@ -29,15 +76,3 @@ def global_options(f: Callable[P, R]) -> Callable[P, R]:
29
76
  is_eager=True,
30
77
  hidden=True,
31
78
  )(f)
32
-
33
-
34
- def interactive_option(f: Callable[P, R]) -> Callable[P, R]:
35
- """Add an interactive option to the command"""
36
-
37
- default = is_interactive_session()
38
- return click.option(
39
- "--interactive/--no-interactive",
40
- help="Run in interactive mode. If not provided, will default to the current session's interactive state.",
41
- is_flag=True,
42
- default=default,
43
- )(f)
@@ -5,6 +5,7 @@ import logging
5
5
  import re
6
6
  from dataclasses import dataclass, field
7
7
  from pathlib import Path
8
+ from textwrap import dedent
8
9
  from urllib.parse import urlsplit
9
10
 
10
11
  from llama_deploy.cli.client import get_project_client as get_client
@@ -23,6 +24,7 @@ from llama_deploy.cli.textual.git_validation import (
23
24
  ValidationResultMessage,
24
25
  )
25
26
  from llama_deploy.cli.textual.secrets_form import SecretsWidget
27
+ from llama_deploy.cli.utils.version import get_installed_appserver_version
26
28
  from llama_deploy.core.deployment_config import (
27
29
  DEFAULT_DEPLOYMENT_NAME,
28
30
  read_deployment_config,
@@ -40,6 +42,7 @@ from llama_deploy.core.schema.deployments import (
40
42
  DeploymentResponse,
41
43
  DeploymentUpdate,
42
44
  )
45
+ from packaging.version import Version
43
46
  from textual import events
44
47
  from textual.app import App, ComposeResult
45
48
  from textual.containers import Container, HorizontalGroup, Widget
@@ -47,7 +50,7 @@ from textual.content import Content
47
50
  from textual.message import Message
48
51
  from textual.reactive import reactive
49
52
  from textual.validation import Length
50
- from textual.widgets import Button, Input, Label, Static
53
+ from textual.widgets import Button, Input, Label, Select, Static
51
54
 
52
55
 
53
56
  @dataclass
@@ -75,11 +78,20 @@ class DeploymentForm:
75
78
  warnings: list[str] = field(default_factory=list)
76
79
  # env info
77
80
  env_info_messages: str | None = None
81
+ # appserver version fields
82
+ installed_appserver_version: str | None = None
83
+ existing_llama_deploy_version: str | None = None
84
+ selected_appserver_version: str | None = None
78
85
 
79
86
  @classmethod
80
87
  def from_deployment(cls, deployment: DeploymentResponse) -> "DeploymentForm":
81
88
  secret_names = deployment.secret_names or []
82
89
 
90
+ installed = get_installed_appserver_version()
91
+ existing = deployment.llama_deploy_version
92
+ # If versions match (or existing is None), treat as non-editable like create
93
+ selected = existing or installed
94
+
83
95
  return DeploymentForm(
84
96
  name=deployment.name,
85
97
  id=deployment.id,
@@ -91,8 +103,15 @@ class DeploymentForm:
91
103
  secrets={},
92
104
  initial_secrets=set(secret_names),
93
105
  is_editing=True,
106
+ installed_appserver_version=installed,
107
+ existing_llama_deploy_version=existing,
108
+ selected_appserver_version=selected,
94
109
  )
95
110
 
111
+ @staticmethod
112
+ def appserver_version() -> str | None:
113
+ return get_installed_appserver_version()
114
+
96
115
  def to_update(self) -> DeploymentUpdate:
97
116
  """Convert form data to API format"""
98
117
 
@@ -100,6 +119,8 @@ class DeploymentForm:
100
119
  for secret in self.removed_secrets:
101
120
  secrets[secret] = None
102
121
 
122
+ appserver_version = self.selected_appserver_version
123
+
103
124
  data = DeploymentUpdate(
104
125
  repo_url=self.repo_url,
105
126
  git_ref=self.git_ref or "main",
@@ -110,12 +131,14 @@ class DeploymentForm:
110
131
  else self.personal_access_token
111
132
  ),
112
133
  secrets=secrets,
134
+ llama_deploy_version=appserver_version,
113
135
  )
114
136
 
115
137
  return data
116
138
 
117
139
  def to_create(self) -> DeploymentCreate:
118
140
  """Convert form data to API format"""
141
+ appserver_version = self.selected_appserver_version
119
142
 
120
143
  return DeploymentCreate(
121
144
  name=self.name,
@@ -124,6 +147,7 @@ class DeploymentForm:
124
147
  git_ref=self.git_ref or "main",
125
148
  personal_access_token=self.personal_access_token,
126
149
  secrets=self.secrets,
150
+ llama_deploy_version=appserver_version,
127
151
  )
128
152
 
129
153
 
@@ -149,12 +173,23 @@ class DeploymentFormWidget(Widget):
149
173
  def compose(self) -> ComposeResult:
150
174
  title = "Edit Deployment" if self.form_data.is_editing else "Create Deployment"
151
175
 
152
- yield Static(
153
- Content.from_markup(
154
- f"{title} [italic][@click=app.show_help()]More info[/][/italic]"
155
- ),
176
+ with HorizontalGroup(
156
177
  classes="primary-message",
157
- )
178
+ ):
179
+ yield Static(
180
+ Content.from_markup(
181
+ f"{title} [italic][@click=app.show_help()]More info[/][/]"
182
+ ),
183
+ classes="w-1fr",
184
+ )
185
+ yield Static(
186
+ Content.from_markup(
187
+ dedent("""
188
+ [italic]Tab or click to navigate.[/]
189
+ """).strip()
190
+ ),
191
+ classes="text-right w-1fr",
192
+ )
158
193
  yield Static(
159
194
  self.error_message,
160
195
  id="error-message",
@@ -200,6 +235,11 @@ class DeploymentFormWidget(Widget):
200
235
  compact=True,
201
236
  )
202
237
 
238
+ yield Static(classes="full-width")
239
+ yield Static(
240
+ Content.from_markup("[italic]Advanced[/]"),
241
+ classes="text-center full-width",
242
+ )
203
243
  yield Label("Config File:", classes="form-label", shrink=True)
204
244
  yield Input(
205
245
  value=self.form_data.deployment_file_path,
@@ -207,6 +247,7 @@ class DeploymentFormWidget(Widget):
207
247
  id="deployment_file_path",
208
248
  compact=True,
209
249
  )
250
+
210
251
  yield Label("Personal Access Token:", classes="form-label", shrink=True)
211
252
  if self.form_data.has_existing_pat:
212
253
  yield Button(
@@ -226,6 +267,45 @@ class DeploymentFormWidget(Widget):
226
267
  compact=True,
227
268
  )
228
269
 
270
+ # Appserver version display/selector
271
+ yield Label("Appserver Version:", classes="form-label", shrink=True)
272
+ versions_differ = (
273
+ self.form_data.is_editing
274
+ and self.form_data.installed_appserver_version
275
+ and self.form_data.existing_llama_deploy_version
276
+ and self.form_data.installed_appserver_version
277
+ != self.form_data.existing_llama_deploy_version
278
+ )
279
+ if versions_differ:
280
+ # Show dropdown selector for version choice
281
+ installed_version = self.form_data.installed_appserver_version
282
+ existing_version = self.form_data.existing_llama_deploy_version
283
+ current_selection = (
284
+ self.form_data.selected_appserver_version
285
+ or existing_version
286
+ or installed_version
287
+ )
288
+ is_upgrade = Version(installed_version) > Version(existing_version)
289
+ label = "Upgrade" if is_upgrade else "Downgrade"
290
+ yield Select(
291
+ [
292
+ (f"{label} to {installed_version}", installed_version),
293
+ (f"Keep {existing_version}", existing_version),
294
+ ],
295
+ value=current_selection,
296
+ id="appserver_version_select",
297
+ allow_blank=False,
298
+ compact=True,
299
+ )
300
+ else:
301
+ # Non-editable display of version
302
+ readonly_version = (
303
+ self.form_data.installed_appserver_version
304
+ or self.form_data.existing_llama_deploy_version
305
+ or "unknown"
306
+ )
307
+ yield Static(readonly_version, id="appserver_version_readonly")
308
+
229
309
  # Secrets section
230
310
  yield SecretsWidget(
231
311
  initial_secrets=self.form_data.secrets,
@@ -249,6 +329,13 @@ class DeploymentFormWidget(Widget):
249
329
  # Post message to parent app to handle cancel
250
330
  self.post_message(CancelFormMessage())
251
331
 
332
+ def on_select_changed(self, event: Select.Changed) -> None:
333
+ """Handle version selection changes"""
334
+ if event.select.id == "appserver_version_select" and event.value:
335
+ updated_form = dataclasses.replace(self.resolve_form_data())
336
+ updated_form.selected_appserver_version = str(event.value)
337
+ self.form_data = updated_form
338
+
252
339
  def _save(self) -> None:
253
340
  self.form_data = self.resolve_form_data()
254
341
  if self._validate_form():
@@ -318,6 +405,9 @@ class DeploymentFormWidget(Widget):
318
405
  removed_secrets=self.original_form_data.initial_secrets.difference(
319
406
  updated_prior_secrets
320
407
  ),
408
+ installed_appserver_version=self.form_data.installed_appserver_version,
409
+ existing_llama_deploy_version=self.form_data.existing_llama_deploy_version,
410
+ selected_appserver_version=self.form_data.selected_appserver_version,
321
411
  )
322
412
 
323
413
 
@@ -583,6 +673,8 @@ def _initialize_deployment_data() -> DeploymentForm:
583
673
  if len(secrets) > 0:
584
674
  env_info_message = "Secrets were automatically seeded from your .env file. Remove or change any that should not be set. They must be manually configured after creation."
585
675
 
676
+ installed = get_installed_appserver_version()
677
+
586
678
  form = DeploymentForm(
587
679
  name=name or "",
588
680
  repo_url=repo_url or "",
@@ -591,6 +683,8 @@ def _initialize_deployment_data() -> DeploymentForm:
591
683
  deployment_file_path=config_file_path or "",
592
684
  warnings=warnings,
593
685
  env_info_messages=env_info_message,
686
+ installed_appserver_version=installed,
687
+ selected_appserver_version=installed,
594
688
  )
595
689
  return form
596
690
 
@@ -39,6 +39,12 @@ class DeploymentHelpWidget(Widget):
39
39
  [b]Config File[/b]
40
40
  Path to a directory or file containing a `[slategrey reverse]pyproject.toml[/]` or `[slategrey reverse]llama_deploy.yaml[/]` containing the llama deploy configuration. Only necessary if you have the configuration not at the root of the repo, or you have an unconventional configuration file.
41
41
 
42
+ [b]Personal Access Token[/b]
43
+ A personal access token to access the git repository. Can be used instead of the github integration.
44
+
45
+ [b]Appserver Version[/b]
46
+ The version of the appserver to deploy. Affects features and functionality. By default this is set to the current llamactl version, and then retained until manually upgraded.
47
+
42
48
  [b]Secrets[/b]
43
49
  Secrets to add as environment variables to the deployment. e.g. to access a database or an API. Supports adding in `[slategrey reverse].env[/]` file format.
44
50
 
@@ -60,6 +60,10 @@ Input.disabled {
60
60
  /* UTILITIES, MESSAGES & NOTIFICATIONS */
61
61
  /* =============================================== */
62
62
 
63
+ .mb-0 {
64
+ margin-bottom: 0 !important;
65
+ }
66
+
63
67
  .mb-1 {
64
68
  margin-bottom: 1;
65
69
  }
@@ -68,6 +72,10 @@ Input.disabled {
68
72
  margin-bottom: 2;
69
73
  }
70
74
 
75
+ .mt-0 {
76
+ margin-top: 0;
77
+ }
78
+
71
79
  .mt-1 {
72
80
  margin-top: 1;
73
81
  }
@@ -76,6 +84,10 @@ Input.disabled {
76
84
  margin-top: 2;
77
85
  }
78
86
 
87
+ .m-0 {
88
+ margin: 0;
89
+ }
90
+
79
91
  .m-1 {
80
92
  margin: 1;
81
93
  }
@@ -102,6 +114,7 @@ Input.disabled {
102
114
  padding: 0 0 0 1
103
115
  }
104
116
 
117
+
105
118
  .error-message {
106
119
  color: $text-error;
107
120
  background: $error-muted;
@@ -201,3 +214,102 @@ Button.secondary {
201
214
  align: left middle;
202
215
  }
203
216
 
217
+
218
+ .top-left {
219
+ align: left top;
220
+ }
221
+ .top-center {
222
+ align: left middle;
223
+ }
224
+ .top-right {
225
+ align: right top;
226
+ }
227
+ .middle-left {
228
+ align: left middle;
229
+ }
230
+ .middle-center {
231
+ align: center middle;
232
+ }
233
+ .middle-right {
234
+ align: right middle;
235
+ }
236
+ .bottom-left {
237
+ align: left bottom;
238
+ }
239
+ .bottom-center {
240
+ align: right bottom;
241
+ }
242
+ .bottom-right {
243
+ align: right bottom;
244
+ }
245
+
246
+
247
+ .text-right {
248
+ text-align: right;
249
+ }
250
+ .text-left {
251
+ text-align: left;
252
+ }
253
+ .text-center {
254
+ text-align: center;
255
+ }
256
+ .text-justify {
257
+ text-align: justify;
258
+ }
259
+
260
+ .background-red {
261
+ background: red;
262
+ }
263
+
264
+
265
+ .w-1 {
266
+ width: 1;
267
+ }
268
+
269
+ .w-2 {
270
+ width: 2;
271
+ }
272
+
273
+ .w-3 {
274
+ width: 3;
275
+ }
276
+
277
+ .w-1fr {
278
+ width: 1fr;
279
+ }
280
+
281
+ .w-100 {
282
+ width: 100%;
283
+ }
284
+
285
+ .w-2fr {
286
+ width: 2fr;
287
+ }
288
+
289
+ .h-1 {
290
+ height: 1;
291
+ }
292
+
293
+ .h-2 {
294
+ height: 2;
295
+ }
296
+
297
+ .h-3 {
298
+ height: 3;
299
+ }
300
+
301
+ .h-1fr {
302
+ height: 1fr;
303
+ }
304
+
305
+ .h-100 {
306
+ height: 100%;
307
+ }
308
+
309
+ .h-2fr {
310
+ height: 2fr;
311
+ }
312
+
313
+ .h-100 {
314
+ height: 100%;
315
+ }
@@ -0,0 +1,11 @@
1
+ """Version utilities shared across CLI components."""
2
+
3
+ from importlib import metadata as importlib_metadata
4
+
5
+
6
+ def get_installed_appserver_version() -> str | None:
7
+ """Return the installed version of `llama-deploy-appserver`, if available."""
8
+ try:
9
+ return importlib_metadata.version("llama-deploy-appserver")
10
+ except Exception:
11
+ return None
File without changes