agentstack-cli 0.5.2rc4__tar.gz → 0.6.0rc1__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 (26) hide show
  1. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/PKG-INFO +1 -1
  2. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/pyproject.toml +1 -1
  3. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/__init__.py +17 -7
  4. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/auth_manager.py +3 -2
  5. agentstack_cli-0.6.0rc1/src/agentstack_cli/commands/connector.py +301 -0
  6. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/commands/platform/__init__.py +26 -16
  7. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/commands/platform/base_driver.py +32 -24
  8. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/commands/platform/wsl_driver.py +2 -0
  9. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/commands/server.py +7 -4
  10. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/commands/user.py +0 -3
  11. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/configuration.py +3 -2
  12. agentstack_cli-0.6.0rc1/src/agentstack_cli/data/helm-chart.tgz +0 -0
  13. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/utils.py +48 -1
  14. agentstack_cli-0.5.2rc4/src/agentstack_cli/commands/platform/istio.py +0 -186
  15. agentstack_cli-0.5.2rc4/src/agentstack_cli/data/helm-chart.tgz +0 -0
  16. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/README.md +0 -0
  17. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/api.py +0 -0
  18. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/async_typer.py +0 -0
  19. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/commands/__init__.py +0 -0
  20. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/commands/agent.py +0 -0
  21. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/commands/build.py +0 -0
  22. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/commands/model.py +0 -0
  23. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/commands/platform/lima_driver.py +0 -0
  24. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/commands/self.py +0 -0
  25. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/console.py +0 -0
  26. {agentstack_cli-0.5.2rc4 → agentstack_cli-0.6.0rc1}/src/agentstack_cli/data/.gitignore +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: agentstack-cli
3
- Version: 0.5.2rc4
3
+ Version: 0.6.0rc1
4
4
  Summary: Agent Stack CLI
5
5
  Author: IBM Corp.
6
6
  Requires-Dist: anyio~=4.10.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentstack-cli"
3
- version = "0.5.2-rc4"
3
+ version = "0.6.0-rc1"
4
4
  description = "Agent Stack CLI"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "IBM Corp." }]
@@ -10,11 +10,13 @@ import typer
10
10
 
11
11
  import agentstack_cli.commands.agent
12
12
  import agentstack_cli.commands.build
13
+ import agentstack_cli.commands.connector
13
14
  import agentstack_cli.commands.model
14
15
  import agentstack_cli.commands.platform
15
16
  import agentstack_cli.commands.self
16
17
  import agentstack_cli.commands.server
17
- import agentstack_cli.commands.user
18
+
19
+ # import agentstack_cli.commands.user
18
20
  from agentstack_cli.async_typer import AsyncTyper
19
21
  from agentstack_cli.configuration import Configuration
20
22
 
@@ -43,6 +45,7 @@ Usage: agentstack [OPTIONS] COMMAND [ARGS]...
43
45
  ╰────────────────────────────────────────────────────────────────────────────╯
44
46
 
45
47
  ╭─ Platform & Configuration ─────────────────────────────────────────────────╮
48
+ | connector Manage connectors to external services │
46
49
  │ model Configure 15+ LLM providers [Admin only] │
47
50
  │ platform Start, stop, or delete local platform [Local only] │
48
51
  │ server Connect to remote Agent Stack servers │
@@ -82,6 +85,12 @@ app.add_typer(
82
85
  no_args_is_help=True,
83
86
  help="Manage agents. Some commands are [Admin only].",
84
87
  )
88
+ app.add_typer(
89
+ agentstack_cli.commands.connector.app,
90
+ name="connector",
91
+ no_args_is_help=True,
92
+ help="Manage connectors to external services.",
93
+ )
85
94
  app.add_typer(
86
95
  agentstack_cli.commands.platform.app,
87
96
  name="platform",
@@ -102,12 +111,13 @@ app.add_typer(
102
111
  help="Manage Agent Stack installation.",
103
112
  hidden=True,
104
113
  )
105
- app.add_typer(
106
- agentstack_cli.commands.user.app,
107
- name="user",
108
- no_args_is_help=True,
109
- help="Manage users. [Admin only]",
110
- )
114
+ # TODO: Implement keycloak integration
115
+ # app.add_typer(
116
+ # agentstack_cli.commands.user.app,
117
+ # name="user",
118
+ # no_args_is_help=True,
119
+ # help="Manage users. [Admin only]",
120
+ # )
111
121
 
112
122
 
113
123
  agent_alias = deepcopy(agentstack_cli.commands.agent.app)
@@ -80,6 +80,7 @@ class AuthManager:
80
80
  """
81
81
  This method exchanges a refresh token for a new access token.
82
82
  """
83
+
83
84
  async with httpx.AsyncClient(headers={"Accept": "application/json"}) as client:
84
85
  resp = None
85
86
  try:
@@ -107,8 +108,8 @@ class AuthManager:
107
108
  "refresh_token": token.refresh_token,
108
109
  "scope": token.scope,
109
110
  "client_id": client_id,
110
- "client_secret": client_secret,
111
- },
111
+ }
112
+ | ({"client_secret": client_secret} if client_secret else {}),
112
113
  )
113
114
  resp.raise_for_status()
114
115
  new_token = resp.json()
@@ -0,0 +1,301 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ import asyncio
4
+ import typing
5
+
6
+ import pydantic
7
+ import typer
8
+ from agentstack_sdk.platform.connector import Connector, ConnectorState
9
+ from agentstack_sdk.platform.types import Metadata
10
+ from InquirerPy import inquirer
11
+ from InquirerPy.base.control import Choice
12
+
13
+ from agentstack_cli import configuration
14
+ from agentstack_cli.async_typer import AsyncTyper
15
+ from agentstack_cli.configuration import Configuration
16
+ from agentstack_cli.console import console
17
+ from agentstack_cli.utils import (
18
+ announce_server_action,
19
+ confirm_server_action,
20
+ )
21
+
22
+ app = AsyncTyper()
23
+ config = Configuration()
24
+
25
+
26
+ @app.command("create")
27
+ async def create_connector(
28
+ url: typing.Annotated[str, typer.Argument(help="Agent location (public docker image or github url)")],
29
+ client_id: typing.Annotated[
30
+ str | None,
31
+ typer.Option("--client-id", help="Client ID for authentication, acquired from env if not supplied"),
32
+ ] = None,
33
+ client_secret: typing.Annotated[
34
+ str | None,
35
+ typer.Option("--client-secret", help="Client secret for authentication, acquired from env if not supplied"),
36
+ ] = None,
37
+ metadata: typing.Annotated[str | None, typer.Option("--metadata", help="Metadata as JSON string")] = None,
38
+ match_preset: typing.Annotated[
39
+ bool, typer.Option("--match-preset", help="Use preset configuration for given url if it exists")
40
+ ] = True,
41
+ ) -> None:
42
+ """Create a connector to an external service."""
43
+ async with configuration.use_platform_client():
44
+ connector = await Connector.create(
45
+ url,
46
+ client_id=client_id if client_id else config.client_id,
47
+ client_secret=client_secret if client_secret else config.client_secret,
48
+ metadata=pydantic.TypeAdapter(Metadata).validate_json(metadata if metadata else "{}"),
49
+ match_preset=match_preset,
50
+ )
51
+ console.success(
52
+ f"Created connector for URL [blue]{connector.url}[/blue] with id: [green]{connector.id}[/green]\n"
53
+ f"Connector status: [yellow]{connector.state}[/yellow]"
54
+ )
55
+
56
+
57
+ def search_path_match_connectors(search_path: str, connectors: list[Connector]) -> list[Connector]:
58
+ return [
59
+ c for c in connectors if (search_path in str(c.id) or search_path.lower() in c.url.unicode_string().lower())
60
+ ]
61
+
62
+
63
+ async def select_connectors_multi(
64
+ search_path: str, connectors: list[Connector], operation_name: str = "remove"
65
+ ) -> list[Connector]:
66
+ """Select multiple connectors matching the search path."""
67
+ connector_candidates = search_path_match_connectors(search_path, connectors)
68
+ if not connector_candidates:
69
+ raise ValueError(f"No matching connectors found for '{search_path}'")
70
+
71
+ if len(connector_candidates) == 1:
72
+ return connector_candidates
73
+
74
+ # Multiple matches - show selection menu
75
+ choices = [Choice(value=c, name=f"{c.url} - {c.id} ({c.state})") for c in connector_candidates]
76
+
77
+ selected_connectors = await inquirer.checkbox( # pyright: ignore[reportPrivateImportUsage]
78
+ message=f"Select connectors to {operation_name} (use ↑/↓ to navigate, Space to select):", choices=choices
79
+ ).execute_async()
80
+
81
+ return selected_connectors or []
82
+
83
+
84
+ @app.command("remove | rm | delete")
85
+ async def remove_connector(
86
+ search_path: typing.Annotated[
87
+ str, typer.Argument(help="Short ID or connector url, supports partial matching")
88
+ ] = "",
89
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
90
+ all: typing.Annotated[bool, typer.Option("--all", "-a", help="Remove all connectors without selection.")] = False,
91
+ ) -> None:
92
+ """Remove connectors."""
93
+
94
+ async def _delete_and_wait_for_completion(connector: Connector) -> None:
95
+ await connector.delete()
96
+ await connector.wait_for_deletion()
97
+
98
+ if search_path and all:
99
+ console.error(
100
+ "[red]Cannot specify both --all and a search path. Use --all to remove all connectors, or provide a search path for specific connectors.[/red]"
101
+ )
102
+ raise typer.Exit(1)
103
+
104
+ async with configuration.use_platform_client():
105
+ connectors_list = await Connector.list()
106
+ connectors = connectors_list.items
107
+ if len(connectors) == 0:
108
+ console.info("[yellow]No connectors found.[/yellow]")
109
+ return
110
+
111
+ if all:
112
+ selected_connectors = connectors
113
+ else:
114
+ selected_connectors = await select_connectors_multi(search_path, connectors, operation_name="remove")
115
+
116
+ if not selected_connectors:
117
+ console.info("[yellow]No connectors selected, exiting.[/yellow]")
118
+ return
119
+ else:
120
+ connector_names = "\n".join([f" - {c.url} - {c.id}" for c in selected_connectors])
121
+
122
+ message = f"\n[bold]Selected connectors to remove:[/bold]\n{connector_names}\n from "
123
+
124
+ url = announce_server_action(message)
125
+ await confirm_server_action("Proceed with removing these connectors from", url=url, yes=yes)
126
+
127
+ with console.status("Removing connector(s)...", spinner="dots"):
128
+ delete_tasks = [_delete_and_wait_for_completion(connector) for connector in selected_connectors]
129
+ results = await asyncio.gather(*delete_tasks, return_exceptions=True)
130
+
131
+ # Check results for exceptions
132
+ successful_deletions = []
133
+ for connector, result in zip(selected_connectors, results, strict=True):
134
+ if isinstance(result, Exception):
135
+ console.error(f"[red]Failed to delete {connector.url}:[/red] {result}")
136
+ else:
137
+ successful_deletions.append(connector)
138
+
139
+ # Wait for successful deletions to complete
140
+ for connector in successful_deletions:
141
+ console.success(f"[green]Successfully deleted connector {connector.url}[/green]")
142
+
143
+
144
+ @app.command("list")
145
+ async def list_connectors() -> None:
146
+ """List all connectors."""
147
+ async with configuration.use_platform_client():
148
+ connectors = await Connector.list()
149
+ message = f"Found [green]{connectors.total_count}[/green] connectors"
150
+ if connectors.total_count > 0:
151
+ message += ":"
152
+ for item in connectors.items:
153
+ message += f"\n- {item.id}: {item.url} ({item.state})"
154
+
155
+ console.success(message)
156
+
157
+
158
+ @app.command("list-presets")
159
+ async def list_connector_presets() -> None:
160
+ """List connector presets."""
161
+ async with configuration.use_platform_client():
162
+ presets = await Connector.presets()
163
+ message = f"Found [green]{presets.total_count}[/green] connector presets:"
164
+ for item in presets.items:
165
+ message += f"\n- {item}"
166
+
167
+ console.success(message)
168
+
169
+
170
+ def find_matching_connector(search_path: str, connectors: list[Connector]) -> Connector:
171
+ connector_candidates = search_path_match_connectors(search_path, connectors)
172
+ if len(connector_candidates) != 1:
173
+ message = f"Found {len(connector_candidates)} matching connectors"
174
+ connector_list = [f" - {c.url} - {c.id} ({c.state})" for c in connector_candidates]
175
+ connectors_detail = ":\n" + "\n".join(connector_list) if connector_list else ""
176
+ raise ValueError(message + connectors_detail)
177
+ [selected_connector] = connector_candidates
178
+ return selected_connector
179
+
180
+
181
+ async def select_connector(search_path: str) -> Connector | None:
182
+ connectors_list = await Connector.list()
183
+ connectors = connectors_list.items
184
+ if connectors_list.total_count == 0:
185
+ console.info("[yellow]No connectors found.[/yellow]")
186
+ return
187
+
188
+ try:
189
+ selected_connector = find_matching_connector(search_path, connectors)
190
+ return selected_connector
191
+ except ValueError as e:
192
+ console.error(e.__str__())
193
+ console.hint("Please refine your input to match exactly one connector id or url.")
194
+ raise typer.Exit(code=1) from None
195
+
196
+
197
+ @app.command("get")
198
+ async def get_connector(
199
+ search_path: typing.Annotated[str, typer.Argument(help="Short ID or connector url, supports partial matching")],
200
+ ) -> None:
201
+ """Get connector details."""
202
+ async with configuration.use_platform_client():
203
+ selected_connector = await select_connector(search_path)
204
+ if not selected_connector:
205
+ return
206
+
207
+ connector = await Connector.get(selected_connector.id)
208
+ connector_data = connector.model_dump()
209
+ message = "Connector details:"
210
+ for key, value in connector_data.items():
211
+ if key in ["auth_request"]:
212
+ continue # Skip auth_request details
213
+ message += f"\n- {key}: {value}"
214
+
215
+ console.success(message)
216
+
217
+
218
+ @app.command("connect")
219
+ async def connect(
220
+ search_path: typing.Annotated[str, typer.Argument(help="Short ID or connector url, supports partial matching")],
221
+ ) -> None:
222
+ """Connect a connector (e.g., start OAuth flow)."""
223
+ async with configuration.use_platform_client():
224
+ selected_connector = await select_connector(search_path)
225
+ if not selected_connector:
226
+ return
227
+
228
+ try:
229
+ with console.status("Connecting connector...", spinner="dots"):
230
+ connector = await selected_connector.connect()
231
+ connector = await connector.wait_for_state(state=ConnectorState.connected)
232
+
233
+ console.success(
234
+ f"[green]Connector connected successfully:[/green] {connector.url} (state: {connector.state})"
235
+ )
236
+ except Exception as e:
237
+ console.error(f"[red]Failed to connect connector:[/red] {e}")
238
+ raise typer.Exit(code=1) from None
239
+
240
+
241
+ @app.command("disconnect")
242
+ async def disconnect(
243
+ search_path: typing.Annotated[
244
+ str, typer.Argument(help="Short ID or connector url, supports partial matching")
245
+ ] = "",
246
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
247
+ all: typing.Annotated[
248
+ bool, typer.Option("--all", "-a", help="Deisconnect all connectors without selection.")
249
+ ] = False,
250
+ ) -> None:
251
+ """Disconnect one or more connectors."""
252
+
253
+ async def _discionnect_and_wait_for_completion(connector: Connector) -> None:
254
+ await connector.disconnect()
255
+ await connector.wait_for_state(state=ConnectorState.disconnected)
256
+
257
+ if search_path and all:
258
+ console.error(
259
+ "[red]Cannot specify both --all and a search path. Use --all to remove all connectors, or provide a search path for specific connectors.[/red]"
260
+ )
261
+ raise typer.Exit(1)
262
+
263
+ async with configuration.use_platform_client():
264
+ connectors_list = await Connector.list()
265
+ connectors = connectors_list.items
266
+ if len(connectors) == 0:
267
+ console.info("[yellow]No connectors found.[/yellow]")
268
+ return
269
+
270
+ if all:
271
+ selected_connectors = connectors
272
+ else:
273
+ selected_connectors = await select_connectors_multi(search_path, connectors, operation_name="disconnect")
274
+
275
+ if not selected_connectors:
276
+ console.info("[yellow]No connectors selected, exiting.[/yellow]")
277
+ return
278
+ else:
279
+ connector_names = "\n".join([f" - {c.url} - {c.id}" for c in selected_connectors])
280
+
281
+ message = f"\n[bold]Selected connectors to disconnect:[/bold]\n{connector_names}\n from "
282
+
283
+ url = announce_server_action(message)
284
+ await confirm_server_action("Proceed with disconnecting these connectors from", url=url, yes=yes)
285
+
286
+ with console.status("Disconnecting connectors...", spinner="dots"):
287
+ disconnect_tasks = [_discionnect_and_wait_for_completion(connector) for connector in selected_connectors]
288
+ results = await asyncio.gather(*disconnect_tasks, return_exceptions=True)
289
+
290
+ # Check results for exceptions
291
+ successful_disconnections = []
292
+ for connector, result in zip(selected_connectors, results, strict=True):
293
+ if isinstance(result, Exception):
294
+ console.error(f"[red]Failed to disconnect {connector.url}:[/red] {result}")
295
+ console.hint("Check that the selected connector is currently connected.")
296
+ else:
297
+ successful_disconnections.append(connector)
298
+
299
+ # Wait for successful disconnections to complete
300
+ for connector in successful_disconnections:
301
+ console.success(f"[green]Successfully disconnected connector[/green] {connector.url}")
@@ -14,18 +14,20 @@ import typing
14
14
 
15
15
  import httpx
16
16
  import typer
17
- from agentstack_sdk.platform import Provider
18
17
  from tenacity import AsyncRetrying, retry_if_exception_type, stop_after_delay, wait_fixed
19
18
 
20
19
  from agentstack_cli.async_typer import AsyncTyper
21
20
  from agentstack_cli.commands.platform.base_driver import BaseDriver
22
21
  from agentstack_cli.commands.platform.lima_driver import LimaDriver
23
22
  from agentstack_cli.commands.platform.wsl_driver import WSLDriver
23
+ from agentstack_cli.configuration import Configuration
24
24
  from agentstack_cli.console import console
25
25
  from agentstack_cli.utils import verbosity
26
26
 
27
27
  app = AsyncTyper()
28
28
 
29
+ configuration = Configuration()
30
+
29
31
 
30
32
  @functools.cache
31
33
  def get_driver(vm_name: str = "agentstack") -> BaseDriver:
@@ -80,6 +82,9 @@ async def start(
80
82
  ] = None,
81
83
  vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack",
82
84
  verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
85
+ skip_pull: typing.Annotated[bool, typer.Option(hidden=True)] = False,
86
+ skip_restart_deployments: typing.Annotated[bool, typer.Option(hidden=True)] = False,
87
+ no_wait_for_platform: typing.Annotated[bool, typer.Option(hidden=True)] = False,
83
88
  ):
84
89
  import agentstack_cli.commands.server
85
90
 
@@ -98,23 +103,28 @@ async def start(
98
103
  values_file=values_file_path,
99
104
  import_images=import_images,
100
105
  pull_on_host=pull_on_host,
106
+ skip_pull=skip_pull,
107
+ skip_restart_deployments=skip_restart_deployments,
101
108
  )
102
109
 
103
- with console.status("Waiting for Agent Stack platform to be ready...", spinner="dots"):
104
- timeout = datetime.timedelta(minutes=20)
105
- try:
106
- async for attempt in AsyncRetrying(
107
- stop=stop_after_delay(timeout),
108
- wait=wait_fixed(datetime.timedelta(seconds=1)),
109
- retry=retry_if_exception_type((httpx.HTTPError, ConnectionError)),
110
- reraise=True,
111
- ):
112
- with attempt:
113
- await Provider.list()
114
- except Exception as ex:
115
- raise ConnectionError(
116
- f"Server did not start in {timeout}. Please check your internet connection."
117
- ) from ex
110
+ if not no_wait_for_platform:
111
+ with console.status("Waiting for Agent Stack platform to be ready...", spinner="dots"):
112
+ timeout = datetime.timedelta(minutes=20)
113
+ async with httpx.AsyncClient() as client:
114
+ try:
115
+ async for attempt in AsyncRetrying(
116
+ stop=stop_after_delay(timeout),
117
+ wait=wait_fixed(datetime.timedelta(seconds=1)),
118
+ retry=retry_if_exception_type((httpx.HTTPError, ConnectionError)),
119
+ reraise=True,
120
+ ):
121
+ with attempt:
122
+ resp = await client.get("http://localhost:8333/healthcheck")
123
+ resp.raise_for_status()
124
+ except Exception as ex:
125
+ raise ConnectionError(
126
+ f"Server did not start in {timeout}. Please check your internet connection."
127
+ ) from ex
118
128
 
119
129
  console.success("Agent Stack platform started successfully!")
120
130
 
@@ -13,9 +13,8 @@ import anyio
13
13
  import yaml
14
14
  from tenacity import AsyncRetrying, stop_after_attempt
15
15
 
16
- import agentstack_cli.commands.platform.istio
17
16
  from agentstack_cli.configuration import Configuration
18
- from agentstack_cli.utils import run_command
17
+ from agentstack_cli.utils import merge, run_command
19
18
 
20
19
 
21
20
  class BaseDriver(abc.ABC):
@@ -106,24 +105,33 @@ class BaseDriver(abc.ABC):
106
105
  values_file: pathlib.Path | None = None,
107
106
  import_images: list[str] | None = None,
108
107
  pull_on_host: bool = False,
108
+ skip_pull: bool = False,
109
+ skip_restart_deployments: bool = False,
109
110
  ) -> None:
110
- await self.run_in_vm(
111
+ _ = await self.run_in_vm(
111
112
  ["sh", "-c", "mkdir -p /tmp/agentstack && cat >/tmp/agentstack/chart.tgz"],
112
113
  "Preparing Helm chart",
113
114
  input=(importlib.resources.files("agentstack_cli") / "data" / "helm-chart.tgz").read_bytes(),
114
115
  )
115
116
  values = {
116
117
  **{svc: {"service": {"type": "LoadBalancer"}} for svc in ["collector", "docling", "ui", "phoenix"]},
117
- "hostNetwork": True,
118
+ "service": {"type": "LoadBalancer"},
118
119
  "externalRegistries": {"public_github": str(Configuration().agent_registry)},
119
120
  "encryptionKey": "Ovx8qImylfooq4-HNwOzKKDcXLZCB3c_m0JlB9eJBxc=",
121
+ "trustProxyHeaders": True,
122
+ "keycloak": {
123
+ "uiClientSecret": "agentstack-ui-secret",
124
+ "serverClientSecret": "agentstack-server-secret",
125
+ "service": {"type": "LoadBalancer"},
126
+ "auth": {"adminPassword": "admin"},
127
+ },
120
128
  "features": {"uiLocalSetup": True},
121
129
  "providerBuilds": {"enabled": True},
122
130
  "localDockerRegistry": {"enabled": True},
123
131
  "auth": {"enabled": False},
124
132
  }
125
133
  if values_file:
126
- values.update(yaml.safe_load(values_file.read_text()))
134
+ values = merge(values, yaml.safe_load(values_file.read_text()))
127
135
  await self.run_in_vm(
128
136
  ["sh", "-c", "cat >/tmp/agentstack/values.yaml"],
129
137
  "Preparing Helm values",
@@ -150,29 +158,29 @@ class BaseDriver(abc.ABC):
150
158
  images_to_import = {canonify(tag) for tag in import_images or []}
151
159
  images_to_pull = required_images - images_to_import
152
160
 
153
- if pull_on_host:
154
- for image in images_to_pull:
155
- await run_command(["docker", "pull", image], f"Pulling image {image} on host")
156
- images_to_import = required_images
157
- images_to_pull = set[str]()
161
+ if not skip_pull:
162
+ if pull_on_host:
163
+ for image in images_to_pull:
164
+ await run_command(["docker", "pull", image], f"Pulling image {image} on host")
165
+ images_to_import = required_images
166
+ images_to_pull = set[str]()
158
167
 
159
- if images_to_import:
160
- await self.import_images(*images_to_import)
168
+ if images_to_import:
169
+ await self.import_images(*images_to_import)
161
170
 
162
- for image in images_to_pull:
163
- async for attempt in AsyncRetrying(stop=stop_after_attempt(5)):
164
- with attempt:
165
- attempt_num = attempt.retry_state.attempt_number
166
- await self.run_in_vm(
167
- ["k3s", "ctr", "image", "pull", image],
168
- f"Pulling image {image}" + (f" (attempt {attempt_num})" if attempt_num > 1 else ""),
169
- )
171
+ for image in images_to_pull:
172
+ async for attempt in AsyncRetrying(stop=stop_after_attempt(5)):
173
+ with attempt:
174
+ attempt_num = attempt.retry_state.attempt_number
175
+ await self.run_in_vm(
176
+ ["k3s", "ctr", "image", "pull", image],
177
+ f"Pulling image {image}" + (f" (attempt {attempt_num})" if attempt_num > 1 else ""),
178
+ )
179
+ elif images_to_import:
180
+ await self.import_images(*images_to_import)
170
181
 
171
182
  self.loaded_images = required_images
172
183
 
173
- if any("auth.oidc.enabled=true" in value.lower() for value in set_values_list):
174
- await agentstack_cli.commands.platform.istio.install(driver=self)
175
-
176
184
  kubeconfig_path = anyio.Path(Configuration().lima_home) / self.vm_name / "copied-from-guest" / "kubeconfig.yaml"
177
185
  await kubeconfig_path.parent.mkdir(parents=True, exist_ok=True)
178
186
  await kubeconfig_path.write_text(
@@ -202,7 +210,7 @@ class BaseDriver(abc.ABC):
202
210
  "Deploying Agent Stack platform with Helm",
203
211
  )
204
212
 
205
- if import_images:
213
+ if import_images and not skip_restart_deployments:
206
214
  await self.run_in_vm(
207
215
  ["k3s", "kubectl", "rollout", "restart", "deployment"],
208
216
  "Restarting deployments to load imported images",
@@ -132,6 +132,8 @@ class WSLDriver(BaseDriver):
132
132
  values_file: pathlib.Path | None = None,
133
133
  import_images: list[str] | None = None,
134
134
  pull_on_host: bool = False,
135
+ skip_pull: bool = False,
136
+ skip_restart_deployments: bool = False,
135
137
  ) -> None:
136
138
  if pull_on_host:
137
139
  raise NotImplementedError("Pulling on host is not supported on this platform.")
@@ -198,10 +198,13 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
198
198
  console.warning(f" Dynamic client registration failed. Proceed with manual input. {e!s}")
199
199
 
200
200
  if not client_id:
201
- client_id = await inquirer.text( # type: ignore
202
- message="Enter Client ID:",
203
- instruction=f"(Redirect URI: {REDIRECT_URI})",
204
- ).execute_async()
201
+ client_id = (
202
+ await inquirer.text( # type: ignore
203
+ message="Enter Client ID (default agentstack-cli):",
204
+ instruction=f"(Redirect URI: {REDIRECT_URI})",
205
+ ).execute_async()
206
+ or "agentstack-cli"
207
+ )
205
208
  if not client_id:
206
209
  raise RuntimeError("Client ID is mandatory. Action cancelled.")
207
210
  client_secret = (
@@ -43,21 +43,18 @@ async def list_users(
43
43
  Column("Email"),
44
44
  Column("Role"),
45
45
  Column("Created"),
46
- Column("Role Updated"),
47
46
  no_wrap=True,
48
47
  ) as table:
49
48
  for user in items:
50
49
  role_display = ROLE_DISPLAY.get(user.role, user.role)
51
50
 
52
51
  created_at = _format_date(user.created_at)
53
- role_updated_at = _format_date(user.role_updated_at) if user.role_updated_at else "-"
54
52
 
55
53
  table.add_row(
56
54
  user.id,
57
55
  user.email,
58
56
  role_display,
59
57
  created_at,
60
- role_updated_at,
61
58
  )
62
59
 
63
60
  console.print()
@@ -35,7 +35,8 @@ class Configuration(pydantic_settings.BaseSettings):
35
35
  agent_registry: pydantic.AnyUrl = HttpUrl(
36
36
  f"https://github.com/i-am-bee/agentstack@v{version()}#path=agent-registry.yaml"
37
37
  )
38
- admin_password: SecretStr | None = None
38
+ username: str = "admin"
39
+ password: SecretStr | None = None
39
40
  server_metadata_ttl: int = 86400
40
41
 
41
42
  oidc_enabled: bool = False
@@ -64,7 +65,7 @@ class Configuration(pydantic_settings.BaseSettings):
64
65
  )
65
66
  sys.exit(1)
66
67
  async with use_platform_client(
67
- auth=("admin", self.admin_password.get_secret_value()) if self.admin_password else None,
68
+ auth=(self.username, self.password.get_secret_value()) if self.password else None,
68
69
  auth_token=await self.auth_manager.load_auth_token(),
69
70
  base_url=self.auth_manager.active_server + "/",
70
71
  ) as client:
@@ -8,7 +8,8 @@ import os
8
8
  import re
9
9
  import subprocess
10
10
  import sys
11
- from collections.abc import AsyncIterator
11
+ from collections import Counter
12
+ from collections.abc import AsyncIterator, Mapping, MutableMapping
12
13
  from contextlib import asynccontextmanager
13
14
  from contextvars import ContextVar
14
15
  from copy import deepcopy
@@ -340,3 +341,49 @@ def is_github_url(url: str) -> bool:
340
341
  $
341
342
  """
342
343
  return bool(re.match(pattern, url, re.VERBOSE))
344
+
345
+
346
+ # Inspired by: https://github.com/clarketm/mergedeep/blob/master/mergedeep/mergedeep.py
347
+
348
+
349
+ def _is_recursive_merge(a: Any, b: Any) -> bool:
350
+ both_mapping = isinstance(a, Mapping) and isinstance(b, Mapping)
351
+ both_counter = isinstance(a, Counter) and isinstance(b, Counter)
352
+ return both_mapping and not both_counter
353
+
354
+
355
+ def _handle_merge_replace(destination, source, key):
356
+ if isinstance(destination[key], Counter) and isinstance(source[key], Counter):
357
+ # Merge both destination and source `Counter` as if they were a standard dict.
358
+ _deepmerge(destination[key], source[key])
359
+ else:
360
+ # If a key exists in both objects and the values are `different`, the value from the `source` object will be used.
361
+ destination[key] = deepcopy(source[key])
362
+
363
+
364
+ def _deepmerge(dst, src):
365
+ for key in src:
366
+ if key in dst:
367
+ if _is_recursive_merge(dst[key], src[key]):
368
+ # If the key for both `dst` and `src` are both Mapping types (e.g. dict), then recurse.
369
+ _deepmerge(dst[key], src[key])
370
+ elif dst[key] is src[key]:
371
+ # If a key exists in both objects and the values are `same`, the value from the `dst` object will be used.
372
+ pass
373
+ else:
374
+ _handle_merge_replace(dst, src, key)
375
+ else:
376
+ # If the key exists only in `src`, the value from the `src` object will be used.
377
+ dst[key] = deepcopy(src[key])
378
+ return dst
379
+
380
+
381
+ def merge(destination: MutableMapping[str, Any], *sources: Mapping[str, Any]) -> MutableMapping[str, Any]:
382
+ """
383
+ A deep merge function for 🐍.
384
+
385
+ :param destination: The destination mapping.
386
+ :param sources: The source mappings.
387
+ :return:
388
+ """
389
+ return functools.reduce(_deepmerge, sources, destination)
@@ -1,186 +0,0 @@
1
- # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
- # SPDX-License-Identifier: Apache-2.0
3
-
4
- import typing
5
-
6
- import yaml
7
-
8
- if typing.TYPE_CHECKING:
9
- from agentstack_cli.commands.platform.base_driver import BaseDriver
10
-
11
-
12
- async def install(driver: "BaseDriver"):
13
- # Gateway API
14
- await driver.run_in_vm(
15
- [
16
- "k3s",
17
- "kubectl",
18
- "apply",
19
- "-f",
20
- "https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.3.0/standard-install.yaml",
21
- ],
22
- "Installing gateway CRDs",
23
- )
24
-
25
- # Cert Manager
26
- await driver.run_in_vm(
27
- [
28
- "helm",
29
- "--kubeconfig=/etc/rancher/k3s/k3s.yaml",
30
- "install",
31
- "cert-manager",
32
- "oci://quay.io/jetstack/charts/cert-manager",
33
- "--version",
34
- "v1.18.2",
35
- "--namespace",
36
- "cert-manager",
37
- "--create-namespace",
38
- "--set",
39
- "crds.enabled=true",
40
- "--wait",
41
- ],
42
- "Installing cert-manager",
43
- )
44
-
45
- # Istio
46
- await driver.run_in_vm(
47
- ["helm", "repo", "add", "istio", "https://istio-release.storage.googleapis.com/charts"],
48
- "Adding Istio repo to Helm",
49
- )
50
- await driver.run_in_vm(["helm", "repo", "update"], "Updating Helm repos")
51
- for component in ["base", "istiod", "cni", "ztunnel"]:
52
- await driver.run_in_vm(
53
- [
54
- "helm",
55
- "--kubeconfig=/etc/rancher/k3s/k3s.yaml",
56
- "install",
57
- f"istio-{component}",
58
- f"istio/{component}",
59
- "--namespace",
60
- "istio-system",
61
- "--create-namespace",
62
- "--set=profile=ambient",
63
- "--set=global.platform=k3s",
64
- "--wait",
65
- ],
66
- f"Installing Istio ({component})",
67
- )
68
- await driver.run_in_vm(
69
- ["k3s", "kubectl", "label", "namespace", "default", "istio.io/dataplane-mode=ambient"],
70
- "Labeling the default namespace",
71
- )
72
-
73
- # Configuration
74
- Resource = typing.TypedDict(
75
- "Resource", {"apiVersion": str, "kind": str, "metadata": dict[str, str], "spec": dict[str, typing.Any]}
76
- )
77
- resources: list[Resource] = [
78
- {
79
- "apiVersion": "cert-manager.io/v1",
80
- "kind": "Issuer",
81
- "metadata": {"name": "default-issuer", "namespace": "default"},
82
- "spec": {"selfSigned": {}},
83
- },
84
- {
85
- "apiVersion": "cert-manager.io/v1",
86
- "kind": "Issuer",
87
- "metadata": {"name": "istio-system-issuer", "namespace": "istio-system"},
88
- "spec": {"selfSigned": {}},
89
- },
90
- {
91
- "apiVersion": "cert-manager.io/v1",
92
- "kind": "Certificate",
93
- "metadata": {"name": "agentstack-tls", "namespace": "istio-system"},
94
- "spec": {
95
- "secretName": "agentstack-tls",
96
- "commonName": "agentstack",
97
- "dnsNames": ["agentstack", "agentstack.localhost"],
98
- "issuerRef": {"name": "istio-system-issuer", "kind": "Issuer"},
99
- },
100
- },
101
- {
102
- "apiVersion": "cert-manager.io/v1",
103
- "kind": "Certificate",
104
- "metadata": {"name": "ingestion-svc", "namespace": "default"},
105
- "spec": {
106
- "secretName": "ingestion-svc-tls",
107
- "commonName": "ingestion-svc",
108
- "dnsNames": [
109
- "ingestion-svc",
110
- "ingestion-svc.default",
111
- "ingestion-svc.default.svc",
112
- "ingestion-svc.default.svc.cluster.local",
113
- ],
114
- "issuerRef": {"name": "default-issuer", "kind": "Issuer"},
115
- },
116
- },
117
- {
118
- "apiVersion": "gateway.networking.k8s.io/v1",
119
- "kind": "Gateway",
120
- "metadata": {"name": "agentstack-gateway", "namespace": "istio-system"},
121
- "spec": {
122
- "gatewayClassName": "istio",
123
- "listeners": [
124
- {
125
- "name": "https",
126
- "hostname": "agentstack.localhost",
127
- "port": 8336,
128
- "protocol": "HTTPS",
129
- "tls": {"mode": "Terminate", "certificateRefs": [{"name": "agentstack-tls"}]},
130
- "allowedRoutes": {"namespaces": {"from": "All"}},
131
- }
132
- ],
133
- },
134
- },
135
- {
136
- "apiVersion": "gateway.networking.k8s.io/v1",
137
- "kind": "HTTPRoute",
138
- "metadata": {"name": "agentstack-ui"},
139
- "spec": {
140
- "parentRefs": [{"name": "agentstack-gateway", "namespace": "istio-system"}],
141
- "hostnames": ["agentstack.testing", "agentstack.localhost"],
142
- "rules": [
143
- {
144
- "matches": [{"path": {"type": "PathPrefix", "value": "/"}}],
145
- "backendRefs": [{"name": "agentstack-ui-svc", "port": 8334}],
146
- }
147
- ],
148
- },
149
- },
150
- ]
151
- for resource in resources:
152
- await driver.run_in_vm(
153
- ["k3s", "kubectl", "apply", "-f", "-"],
154
- f"Applying {resource['metadata']['name']} ({resource['kind']})",
155
- input=yaml.dump(resource, sort_keys=False).encode("utf-8"),
156
- )
157
-
158
- # Extra services
159
- for addon in ["prometheus", "kiali"]:
160
- await driver.run_in_vm(
161
- [
162
- "k3s",
163
- "kubectl",
164
- "apply",
165
- "-f",
166
- f"https://raw.githubusercontent.com/istio/istio/master/samples/addons/{addon}.yaml",
167
- ],
168
- f"Installing {addon.capitalize()}",
169
- )
170
- await driver.run_in_vm(
171
- [
172
- "k3s",
173
- "kubectl",
174
- "-n",
175
- "istio-system",
176
- "expose",
177
- "deployment",
178
- "kiali",
179
- "--protocol=TCP",
180
- "--port=20001",
181
- "--target-port=20001",
182
- "--type=LoadBalancer",
183
- "--name=kiali-external",
184
- ],
185
- "Exposing Kiali service",
186
- )