agentstack-cli 0.6.0rc1__py3-none-manylinux_2_34_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import sys
9
+ import typing
10
+ import uuid
11
+ from asyncio import CancelledError
12
+ from contextlib import suppress
13
+ from datetime import timedelta
14
+ from pathlib import Path
15
+
16
+ import anyio
17
+ import anyio.abc
18
+ import typer
19
+ from a2a.utils import AGENT_CARD_WELL_KNOWN_PATH
20
+ from agentstack_sdk.platform import AddProvider, BuildConfiguration, Provider, UpdateProvider
21
+ from agentstack_sdk.platform.provider_build import ProviderBuild
22
+ from anyio import open_process
23
+ from httpx import AsyncClient, HTTPError
24
+ from tenacity import AsyncRetrying, retry_if_exception_type, stop_after_delay, wait_fixed
25
+
26
+ from agentstack_cli.async_typer import AsyncTyper
27
+ from agentstack_cli.console import console, err_console
28
+ from agentstack_cli.utils import (
29
+ announce_server_action,
30
+ capture_output,
31
+ confirm_server_action,
32
+ extract_messages,
33
+ print_log,
34
+ run_command,
35
+ status,
36
+ verbosity,
37
+ )
38
+
39
+
40
+ async def find_free_port():
41
+ """Get a random free port assigned by the OS."""
42
+ listener = await anyio.create_tcp_listener()
43
+ port = listener.extra(anyio.abc.SocketAttribute.local_address)[1]
44
+ await listener.aclose()
45
+ return port
46
+
47
+
48
+ app = AsyncTyper()
49
+
50
+
51
+ @app.command("client-side-build")
52
+ async def client_side_build(
53
+ context: typing.Annotated[str, typer.Argument(help="Docker context for the agent")] = ".",
54
+ dockerfile: typing.Annotated[str | None, typer.Option(help="Use custom dockerfile path")] = None,
55
+ tag: typing.Annotated[str | None, typer.Option(help="Docker tag for the agent")] = None,
56
+ multi_platform: bool | None = False,
57
+ push: typing.Annotated[bool, typer.Option(help="Push the image to the target registry.")] = False,
58
+ import_image: typing.Annotated[
59
+ bool, typer.Option("--import/--no-import", is_flag=True, help="Import the image into Agent Stack platform")
60
+ ] = True,
61
+ vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack",
62
+ verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
63
+ ):
64
+ """Build agent locally using Docker. [Local only]"""
65
+ with verbosity(verbose):
66
+ await run_command(["which", "docker"], "Checking docker")
67
+ image_id = "agentstack-agent-build-tmp:latest"
68
+ port = await find_free_port()
69
+ dockerfile_args = ("-f", dockerfile) if dockerfile else ()
70
+
71
+ await run_command(
72
+ ["docker", "build", context, *dockerfile_args, "-t", image_id],
73
+ "Building agent image",
74
+ )
75
+
76
+ agent_card = None
77
+
78
+ container_id = str(uuid.uuid4())
79
+
80
+ with status("Extracting agent metadata"):
81
+ async with (
82
+ await open_process(
83
+ f"docker run --name {container_id} --rm -p {port}:8000 -e HOST=0.0.0.0 -e PORT=8000 {image_id}",
84
+ ) as process,
85
+ ):
86
+ async with capture_output(process) as task_group:
87
+ try:
88
+ async for attempt in AsyncRetrying(
89
+ stop=stop_after_delay(timedelta(seconds=30)),
90
+ wait=wait_fixed(timedelta(seconds=0.5)),
91
+ retry=retry_if_exception_type(HTTPError),
92
+ reraise=True,
93
+ ):
94
+ with attempt:
95
+ async with AsyncClient() as client:
96
+ resp = await client.get(
97
+ f"http://localhost:{port}{AGENT_CARD_WELL_KNOWN_PATH}", timeout=1
98
+ )
99
+ resp.raise_for_status()
100
+ agent_card = resp.json()
101
+ process.terminate()
102
+ with suppress(ProcessLookupError):
103
+ process.kill()
104
+ except BaseException as ex:
105
+ raise RuntimeError(f"Failed to build agent: {extract_messages(ex)}") from ex
106
+ finally:
107
+ task_group.cancel_scope.cancel()
108
+ with suppress(BaseException):
109
+ await run_command(["docker", "kill", container_id], "Killing container")
110
+ with suppress(ProcessLookupError):
111
+ process.kill()
112
+
113
+ context_hash = hashlib.sha256((context + (dockerfile or "")).encode()).hexdigest()[:6]
114
+ context_shorter = re.sub(r"https?://", "", context).replace(r".git", "")
115
+ context_shorter = re.sub(r"[^a-zA-Z0-9_-]+", "-", context_shorter)[:32].lstrip("-") or "provider"
116
+ tag = (tag or f"agentstack-registry-svc.default:5001/{context_shorter}-{context_hash}:latest").lower()
117
+ await run_command(
118
+ command=[
119
+ *(
120
+ ["docker", "buildx", "build", "--platform=linux/amd64,linux/arm64"]
121
+ if multi_platform
122
+ else ["docker", "build"]
123
+ ),
124
+ "--push" if push else "--load",
125
+ context,
126
+ *dockerfile_args,
127
+ "-t",
128
+ tag,
129
+ f"--label=beeai.dev.agent.json={base64.b64encode(json.dumps(agent_card).encode()).decode()}",
130
+ ],
131
+ message="Adding agent labels to container",
132
+ check=True,
133
+ )
134
+ console.success(f"Successfully built agent: {tag}")
135
+ if import_image:
136
+ from agentstack_cli.commands.platform import get_driver
137
+
138
+ if "agentstack-registry-svc.default" not in tag:
139
+ source_tag = tag
140
+ tag = re.sub("^[^/]*/", "agentstack-registry-svc.default:5001/", tag)
141
+ await run_command(["docker", "tag", source_tag, tag], "Tagging image")
142
+
143
+ driver = get_driver(vm_name=vm_name)
144
+
145
+ if (await driver.status()) != "running":
146
+ console.error("Agent Stack platform is not running.")
147
+ sys.exit(1)
148
+
149
+ await driver.import_image_to_internal_registry(tag)
150
+ console.success(
151
+ "Agent was imported to the agent stack internal registry.\n"
152
+ + f"You can add it using [blue]agentstack add {tag}[/blue]"
153
+ )
154
+
155
+ return tag, agent_card
156
+
157
+
158
+ async def _server_side_build(
159
+ github_url: str,
160
+ dockerfile: str | None = None,
161
+ replace: str | None = None,
162
+ add: bool = False,
163
+ verbose: bool = False,
164
+ ) -> ProviderBuild:
165
+ build = None
166
+ from agentstack_cli.commands.agent import select_provider
167
+ from agentstack_cli.configuration import Configuration
168
+
169
+ try:
170
+ if replace and add:
171
+ raise ValueError("Cannot specify both replace and add options.")
172
+
173
+ build_configuration = None
174
+ if dockerfile:
175
+ build_configuration = BuildConfiguration(dockerfile_path=Path(dockerfile))
176
+
177
+ async with Configuration().use_platform_client():
178
+ on_complete = None
179
+ if replace:
180
+ provider = select_provider(replace, await Provider.list())
181
+ on_complete = UpdateProvider(provider_id=uuid.UUID(provider.id))
182
+ elif add:
183
+ on_complete = AddProvider()
184
+
185
+ build = await ProviderBuild.create(
186
+ location=github_url,
187
+ on_complete=on_complete,
188
+ build_configuration=build_configuration,
189
+ )
190
+ with verbosity(verbose):
191
+ async for message in build.stream_logs():
192
+ print_log(message, ansi_mode=True, out_console=err_console)
193
+ return await build.get()
194
+ except (KeyboardInterrupt, CancelledError):
195
+ async with Configuration().use_platform_client():
196
+ if build:
197
+ await build.delete()
198
+ console.error("Build aborted.")
199
+ raise
200
+
201
+
202
+ @app.command("build")
203
+ async def server_side_build(
204
+ github_url: typing.Annotated[
205
+ str, typer.Argument(..., help="Github repository URL (public or private if supported by the platform instance)")
206
+ ],
207
+ dockerfile: typing.Annotated[
208
+ str | None, typer.Option(help="Use custom dockerfile path, relative to github url sub-path")
209
+ ] = None,
210
+ verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
211
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
212
+ ):
213
+ """Build agent from a GitHub repository in the platform. [Admin only]"""
214
+
215
+ url = announce_server_action(f"Starting build for '{github_url}' on")
216
+ await confirm_server_action("Proceed with building this agent on", url=url, yes=yes)
217
+
218
+ build = await _server_side_build(github_url=github_url, dockerfile=dockerfile, verbose=verbose)
219
+
220
+ console.success(
221
+ f"Agent built successfully, add it to the platform using: [green]agentstack add {build.destination}[/green]"
222
+ )
@@ -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}")