agentstack-cli 0.4.0__py3-none-macosx_12_0_arm64.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,184 @@
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 contextlib import suppress
12
+ from datetime import timedelta
13
+ from pathlib import Path
14
+
15
+ import anyio
16
+ import anyio.abc
17
+ import typer
18
+ from a2a.utils import AGENT_CARD_WELL_KNOWN_PATH
19
+ from agentstack_sdk.platform import AddProvider, BuildConfiguration, Provider, UpdateProvider
20
+ from agentstack_sdk.platform.provider_build import BuildState, ProviderBuild
21
+ from anyio import open_process
22
+ from httpx import AsyncClient, HTTPError
23
+ from tenacity import AsyncRetrying, retry_if_exception_type, stop_after_delay, wait_fixed
24
+
25
+ from agentstack_cli.async_typer import AsyncTyper
26
+ from agentstack_cli.console import console
27
+ from agentstack_cli.utils import capture_output, extract_messages, print_log, run_command, status, verbosity
28
+
29
+
30
+ async def find_free_port():
31
+ """Get a random free port assigned by the OS."""
32
+ listener = await anyio.create_tcp_listener()
33
+ port = listener.extra(anyio.abc.SocketAttribute.local_address)[1]
34
+ await listener.aclose()
35
+ return port
36
+
37
+
38
+ app = AsyncTyper()
39
+
40
+
41
+ @app.command("build")
42
+ async def build(
43
+ context: typing.Annotated[str, typer.Argument(help="Docker context for the agent")] = ".",
44
+ dockerfile: typing.Annotated[str | None, typer.Option(help="Use custom dockerfile path")] = None,
45
+ tag: typing.Annotated[str | None, typer.Option(help="Docker tag for the agent")] = None,
46
+ multi_platform: bool | None = False,
47
+ push: typing.Annotated[bool, typer.Option(help="Push the image to the target registry.")] = False,
48
+ import_image: typing.Annotated[
49
+ bool, typer.Option("--import/--no-import", is_flag=True, help="Import the image into Agent Stack platform")
50
+ ] = True,
51
+ vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack",
52
+ verbose: typing.Annotated[bool, typer.Option("-v")] = False,
53
+ ):
54
+ with verbosity(verbose):
55
+ await run_command(["which", "docker"], "Checking docker")
56
+ image_id = "agentstack-agent-build-tmp:latest"
57
+ port = await find_free_port()
58
+ dockerfile_args = ("-f", dockerfile) if dockerfile else ()
59
+
60
+ await run_command(
61
+ ["docker", "build", context, *dockerfile_args, "-t", image_id],
62
+ "Building agent image",
63
+ )
64
+
65
+ agent_card = None
66
+
67
+ container_id = str(uuid.uuid4())
68
+
69
+ with status("Extracting agent metadata"):
70
+ async with (
71
+ await open_process(
72
+ f"docker run --name {container_id} --rm -p {port}:8000 -e HOST=0.0.0.0 -e PORT=8000 {image_id}",
73
+ ) as process,
74
+ ):
75
+ async with capture_output(process) as task_group:
76
+ try:
77
+ async for attempt in AsyncRetrying(
78
+ stop=stop_after_delay(timedelta(seconds=30)),
79
+ wait=wait_fixed(timedelta(seconds=0.5)),
80
+ retry=retry_if_exception_type(HTTPError),
81
+ reraise=True,
82
+ ):
83
+ with attempt:
84
+ async with AsyncClient() as client:
85
+ resp = await client.get(
86
+ f"http://localhost:{port}{AGENT_CARD_WELL_KNOWN_PATH}", timeout=1
87
+ )
88
+ resp.raise_for_status()
89
+ agent_card = resp.json()
90
+ process.terminate()
91
+ with suppress(ProcessLookupError):
92
+ process.kill()
93
+ except BaseException as ex:
94
+ raise RuntimeError(f"Failed to build agent: {extract_messages(ex)}") from ex
95
+ finally:
96
+ task_group.cancel_scope.cancel()
97
+ with suppress(BaseException):
98
+ await run_command(["docker", "kill", container_id], "Killing container")
99
+ with suppress(ProcessLookupError):
100
+ process.kill()
101
+
102
+ context_hash = hashlib.sha256((context + (dockerfile or "")).encode()).hexdigest()[:6]
103
+ context_shorter = re.sub(r"https?://", "", context).replace(r".git", "")
104
+ context_shorter = re.sub(r"[^a-zA-Z0-9_-]+", "-", context_shorter)[:32].lstrip("-") or "provider"
105
+ tag = (tag or f"agentstack.local/{context_shorter}-{context_hash}:latest").lower()
106
+ await run_command(
107
+ command=[
108
+ *(
109
+ ["docker", "buildx", "build", "--platform=linux/amd64,linux/arm64"]
110
+ if multi_platform
111
+ else ["docker", "build"]
112
+ ),
113
+ "--push" if push else "--load",
114
+ context,
115
+ *dockerfile_args,
116
+ "-t",
117
+ tag,
118
+ f"--label=beeai.dev.agent.json={base64.b64encode(json.dumps(agent_card).encode()).decode()}",
119
+ ],
120
+ message="Adding agent labels to container",
121
+ check=True,
122
+ )
123
+ console.success(f"Successfully built agent: {tag}")
124
+ if import_image:
125
+ from agentstack_cli.commands.platform import get_driver
126
+
127
+ driver = get_driver(vm_name=vm_name)
128
+ if (await driver.status()) != "running":
129
+ console.error("Agent Stack platform is not running.")
130
+ sys.exit(1)
131
+ await driver.import_image(tag)
132
+
133
+ return tag, agent_card
134
+
135
+
136
+ @app.command("server-side-build")
137
+ async def server_side_build_experimental(
138
+ github_url: typing.Annotated[
139
+ str, typer.Argument(..., help="Github repository URL (public or private if supported by the platform instance)")
140
+ ],
141
+ dockerfile: typing.Annotated[
142
+ str | None, typer.Option(help="Use custom dockerfile path, relative to github url sub-path")
143
+ ] = None,
144
+ replace: typing.Annotated[
145
+ str | None, typer.Option(help="Short ID, agent name or part of the provider location")
146
+ ] = None,
147
+ add: typing.Annotated[bool, typer.Option(help="Add agent to the platform after build")] = False,
148
+ ):
149
+ """EXPERIMENTAL: Build agent from github repository in the platform."""
150
+ from agentstack_cli.commands.agent import select_provider
151
+ from agentstack_cli.configuration import Configuration
152
+
153
+ if replace and add:
154
+ raise ValueError("Cannot specify both replace and add options.")
155
+
156
+ build_configuration = None
157
+ if dockerfile:
158
+ build_configuration = BuildConfiguration(dockerfile_path=Path(dockerfile))
159
+
160
+ async with Configuration().use_platform_client():
161
+ on_complete = None
162
+ if replace:
163
+ provider = select_provider(replace, await Provider.list())
164
+ on_complete = UpdateProvider(provider_id=uuid.UUID(provider.id))
165
+ elif add:
166
+ on_complete = AddProvider()
167
+
168
+ build = await ProviderBuild.create(
169
+ location=github_url,
170
+ on_complete=on_complete,
171
+ build_configuration=build_configuration,
172
+ )
173
+ async for message in build.stream_logs():
174
+ print_log(message, ansi_mode=True)
175
+ build = await build.get()
176
+ if build.status == BuildState.COMPLETED:
177
+ if add:
178
+ message = "Agent added successfully. List agents using [green]agentstack list[/green]"
179
+ else:
180
+ message = f"Agent built successfully, add it to the platform using: [green]agentstack add {build.destination}[/green]"
181
+ console.success(message)
182
+ else:
183
+ error = build.error_message or "see logs above for details"
184
+ console.error(f"Agent build failed: {error}")
@@ -0,0 +1,141 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import typing
5
+ from enum import StrEnum
6
+
7
+ import typer
8
+ from rich.table import Column
9
+
10
+ from agentstack_cli.api import api_request
11
+ from agentstack_cli.async_typer import AsyncTyper, console, create_table
12
+ from agentstack_cli.utils import (
13
+ status,
14
+ )
15
+
16
+ app = AsyncTyper()
17
+
18
+
19
+ class Transport(StrEnum):
20
+ SSE = "sse"
21
+ STREAMABLE_HTTP = "streamable_http"
22
+
23
+
24
+ @app.command("add")
25
+ async def add_provider(
26
+ name: typing.Annotated[str, typer.Argument(help="Name for the MCP server")],
27
+ location: typing.Annotated[str, typer.Argument(help="Location of the MCP server")],
28
+ transport: typing.Annotated[
29
+ Transport, typer.Argument(help="Transport the MCP server uses")
30
+ ] = Transport.STREAMABLE_HTTP,
31
+ ) -> None:
32
+ """Install discovered MCP server."""
33
+
34
+ with status("Registering server to platform"):
35
+ await api_request(
36
+ "POST", "mcp/providers", json={"name": name, "location": location, "transport": transport.value}
37
+ )
38
+ console.print("Registering server to platform [[green]DONE[/green]]")
39
+ await list_providers()
40
+
41
+
42
+ @app.command("list")
43
+ async def list_providers():
44
+ """List MCP servers."""
45
+
46
+ providers = await api_request("GET", "mcp/providers")
47
+ assert providers
48
+ with create_table(
49
+ Column("Name"),
50
+ Column("Location"),
51
+ Column("Transport"),
52
+ Column("State"),
53
+ no_wrap=True,
54
+ ) as table:
55
+ for provider in providers:
56
+ table.add_row(provider["name"], provider["location"], provider["transport"], provider["state"])
57
+ console.print()
58
+ console.print(table)
59
+
60
+
61
+ @app.command("remove | uninstall | rm | delete")
62
+ async def uninstall_provider(
63
+ name: typing.Annotated[str, typer.Argument(help="Name of the MCP provider to remove")],
64
+ ) -> None:
65
+ """Remove MCP server."""
66
+ provider = await _get_provider_by_name(name)
67
+ if provider:
68
+ await api_request("delete", f"mcp/providers/{provider['id']}")
69
+ else:
70
+ raise ValueError(f"Provider {name} not found")
71
+ await list_providers()
72
+
73
+
74
+ tool_app = AsyncTyper()
75
+ app.add_typer(tool_app, name="tool", no_args_is_help=True, help="Inspect tools.")
76
+
77
+
78
+ @tool_app.command("list")
79
+ async def list_tools() -> None:
80
+ """List tools."""
81
+
82
+ tools = await api_request("GET", "mcp/tools")
83
+ assert tools
84
+ with create_table(
85
+ Column("Name"),
86
+ Column("Description", max_width=30),
87
+ no_wrap=True,
88
+ ) as table:
89
+ for tool in tools:
90
+ table.add_row(tool["name"], tool["description"])
91
+ console.print()
92
+ console.print(table)
93
+
94
+
95
+ toolkit_app = AsyncTyper()
96
+ app.add_typer(toolkit_app, name="toolkit", no_args_is_help=True, help="Create toolkits.")
97
+
98
+
99
+ @toolkit_app.command("create")
100
+ async def toolkit(
101
+ tools: typing.Annotated[list[str], typer.Argument(help="Tools to put in the toolkit")],
102
+ ) -> None:
103
+ """Create a toolkit."""
104
+
105
+ api_tools = await _get_tools_by_names(tools)
106
+ assert api_tools
107
+ toolkit = await api_request("POST", "mcp/toolkits", json={"tools": [tool["id"] for tool in api_tools]})
108
+ assert toolkit
109
+ with create_table(Column("Location"), Column("Transport"), Column("Expiration")) as table:
110
+ table.add_row(toolkit["location"], toolkit["transport"], toolkit["expires_at"])
111
+ console.print()
112
+ console.print(table)
113
+
114
+
115
+ async def _get_provider_by_name(name: str):
116
+ providers = await api_request("GET", "mcp/providers")
117
+ assert providers
118
+
119
+ for provider in providers:
120
+ if provider["name"] == name:
121
+ return provider
122
+
123
+ raise ValueError(f"Provider {name} not found")
124
+
125
+
126
+ async def _get_tools_by_names(names: list[str]) -> list[dict[str, typing.Any]]:
127
+ all_tools = await api_request("GET", "mcp/tools")
128
+ assert all_tools
129
+
130
+ tools = []
131
+ for name in names:
132
+ found = False
133
+ for tool in all_tools:
134
+ if tool["name"] == name:
135
+ tools.append(tool)
136
+ found = True
137
+ break
138
+ if not found:
139
+ raise ValueError(f"Tool {name} not found")
140
+
141
+ return tools