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.
- agentstack_cli/__init__.py +77 -0
- agentstack_cli/api.py +125 -0
- agentstack_cli/async_typer.py +104 -0
- agentstack_cli/auth_manager.py +115 -0
- agentstack_cli/commands/__init__.py +3 -0
- agentstack_cli/commands/agent.py +1077 -0
- agentstack_cli/commands/build.py +184 -0
- agentstack_cli/commands/mcp.py +141 -0
- agentstack_cli/commands/model.py +624 -0
- agentstack_cli/commands/platform/__init__.py +181 -0
- agentstack_cli/commands/platform/base_driver.py +222 -0
- agentstack_cli/commands/platform/istio.py +186 -0
- agentstack_cli/commands/platform/lima_driver.py +210 -0
- agentstack_cli/commands/platform/wsl_driver.py +226 -0
- agentstack_cli/commands/self.py +206 -0
- agentstack_cli/commands/server.py +237 -0
- agentstack_cli/configuration.py +76 -0
- agentstack_cli/console.py +25 -0
- agentstack_cli/data/.gitignore +2 -0
- agentstack_cli/data/helm-chart.tgz +0 -0
- agentstack_cli/data/lima-guestagent.Linux-aarch64.gz +0 -0
- agentstack_cli/data/limactl +0 -0
- agentstack_cli/utils.py +281 -0
- agentstack_cli-0.4.0.dist-info/METADATA +104 -0
- agentstack_cli-0.4.0.dist-info/RECORD +27 -0
- agentstack_cli-0.4.0.dist-info/WHEEL +4 -0
- agentstack_cli-0.4.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import typing
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
import agentstack_cli.commands.agent
|
|
11
|
+
import agentstack_cli.commands.build
|
|
12
|
+
import agentstack_cli.commands.mcp
|
|
13
|
+
import agentstack_cli.commands.model
|
|
14
|
+
import agentstack_cli.commands.platform
|
|
15
|
+
import agentstack_cli.commands.self
|
|
16
|
+
import agentstack_cli.commands.server
|
|
17
|
+
from agentstack_cli.async_typer import AsyncTyper
|
|
18
|
+
from agentstack_cli.configuration import Configuration
|
|
19
|
+
|
|
20
|
+
logging.basicConfig(level=logging.INFO if Configuration().debug else logging.FATAL)
|
|
21
|
+
logging.getLogger("httpx").setLevel(logging.WARNING) # not sure why this is necessary
|
|
22
|
+
|
|
23
|
+
app = AsyncTyper(no_args_is_help=True)
|
|
24
|
+
app.add_typer(agentstack_cli.commands.model.app, name="model", no_args_is_help=True, help="Manage model providers.")
|
|
25
|
+
app.add_typer(agentstack_cli.commands.agent.app, name="agent", no_args_is_help=True, help="Manage agents.")
|
|
26
|
+
app.add_typer(
|
|
27
|
+
agentstack_cli.commands.platform.app, name="platform", no_args_is_help=True, help="Manage Agent Stack platform."
|
|
28
|
+
)
|
|
29
|
+
app.add_typer(
|
|
30
|
+
agentstack_cli.commands.mcp.app, name="mcp", no_args_is_help=True, help="Manage MCP servers and toolkits."
|
|
31
|
+
)
|
|
32
|
+
app.add_typer(agentstack_cli.commands.build.app, name="", no_args_is_help=True, help="Build agent images.")
|
|
33
|
+
app.add_typer(
|
|
34
|
+
agentstack_cli.commands.server.app,
|
|
35
|
+
name="server",
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
help="Manage Agent Stack servers and authentication.",
|
|
38
|
+
)
|
|
39
|
+
app.add_typer(
|
|
40
|
+
agentstack_cli.commands.self.app,
|
|
41
|
+
name="self",
|
|
42
|
+
no_args_is_help=True,
|
|
43
|
+
help="Manage Agent Stack installation.",
|
|
44
|
+
hidden=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
agent_alias = deepcopy(agentstack_cli.commands.agent.app)
|
|
49
|
+
for cmd in agent_alias.registered_commands:
|
|
50
|
+
cmd.rich_help_panel = "Agent commands"
|
|
51
|
+
|
|
52
|
+
app.add_typer(agent_alias, name="", no_args_is_help=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command("version")
|
|
56
|
+
async def version(verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False):
|
|
57
|
+
"""Print version of the Agent Stack CLI."""
|
|
58
|
+
import agentstack_cli.commands.self
|
|
59
|
+
|
|
60
|
+
await agentstack_cli.commands.self.version(verbose=verbose)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command("ui")
|
|
64
|
+
async def ui():
|
|
65
|
+
"""Launch the graphical interface."""
|
|
66
|
+
import webbrowser
|
|
67
|
+
|
|
68
|
+
import agentstack_cli.commands.model
|
|
69
|
+
|
|
70
|
+
await agentstack_cli.commands.model.ensure_llm_provider()
|
|
71
|
+
webbrowser.open(
|
|
72
|
+
"http://localhost:8334"
|
|
73
|
+
) # TODO: This always opens the local UI, how to open the UI of a logged in server instead?
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
app()
|
agentstack_cli/api.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import urllib
|
|
6
|
+
import urllib.parse
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import openai
|
|
14
|
+
from a2a.client import Client, ClientConfig, ClientFactory
|
|
15
|
+
from a2a.types import AgentCard
|
|
16
|
+
from httpx import HTTPStatusError
|
|
17
|
+
from httpx._types import RequestFiles
|
|
18
|
+
|
|
19
|
+
from agentstack_cli import configuration
|
|
20
|
+
from agentstack_cli.configuration import Configuration
|
|
21
|
+
|
|
22
|
+
config = Configuration()
|
|
23
|
+
|
|
24
|
+
API_BASE_URL = "api/v1/"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def api_request(
|
|
28
|
+
method: str,
|
|
29
|
+
path: str,
|
|
30
|
+
json: dict | None = None,
|
|
31
|
+
files: RequestFiles | None = None,
|
|
32
|
+
params: dict[str, Any] | None = None,
|
|
33
|
+
use_auth: bool = True,
|
|
34
|
+
) -> dict | None:
|
|
35
|
+
"""Make an API request to the server."""
|
|
36
|
+
async with configuration.use_platform_client() as client:
|
|
37
|
+
response = await client.request(
|
|
38
|
+
method,
|
|
39
|
+
urllib.parse.urljoin(API_BASE_URL, path),
|
|
40
|
+
json=json,
|
|
41
|
+
files=files,
|
|
42
|
+
params=params,
|
|
43
|
+
timeout=60,
|
|
44
|
+
headers=(
|
|
45
|
+
{"Authorization": f"Bearer {token}"}
|
|
46
|
+
if use_auth and (token := config.auth_manager.load_auth_token())
|
|
47
|
+
else {}
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
if response.is_error:
|
|
51
|
+
error = ""
|
|
52
|
+
try:
|
|
53
|
+
error = response.json()
|
|
54
|
+
error = error.get("detail", str(error))
|
|
55
|
+
except Exception:
|
|
56
|
+
response.raise_for_status()
|
|
57
|
+
if response.status_code == 401:
|
|
58
|
+
message = f'{error}\nexport AGENTSTACK__ADMIN_PASSWORD="<PASSWORD>" to set the admin password.'
|
|
59
|
+
raise HTTPStatusError(message=message, request=response.request, response=response)
|
|
60
|
+
raise HTTPStatusError(message=error, request=response.request, response=response)
|
|
61
|
+
if response.content:
|
|
62
|
+
return response.json()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def api_stream(
|
|
66
|
+
method: str,
|
|
67
|
+
path: str,
|
|
68
|
+
json: dict | None = None,
|
|
69
|
+
params: dict[str, Any] | None = None,
|
|
70
|
+
use_auth: bool = True,
|
|
71
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
72
|
+
"""Make a streaming API request to the server."""
|
|
73
|
+
import json as jsonlib
|
|
74
|
+
|
|
75
|
+
async with (
|
|
76
|
+
configuration.use_platform_client() as client,
|
|
77
|
+
client.stream(
|
|
78
|
+
method,
|
|
79
|
+
urllib.parse.urljoin(API_BASE_URL, path),
|
|
80
|
+
json=json,
|
|
81
|
+
params=params,
|
|
82
|
+
timeout=timedelta(hours=1).total_seconds(),
|
|
83
|
+
headers=(
|
|
84
|
+
{"Authorization": f"Bearer {token}"}
|
|
85
|
+
if use_auth and (token := config.auth_manager.load_auth_token())
|
|
86
|
+
else {}
|
|
87
|
+
),
|
|
88
|
+
) as response,
|
|
89
|
+
):
|
|
90
|
+
response: httpx.Response
|
|
91
|
+
if response.is_error:
|
|
92
|
+
error = ""
|
|
93
|
+
try:
|
|
94
|
+
[error] = [jsonlib.loads(message) async for message in response.aiter_text()]
|
|
95
|
+
error = error.get("detail", str(error))
|
|
96
|
+
except Exception:
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
raise HTTPStatusError(message=error, request=response.request, response=response)
|
|
99
|
+
async for line in response.aiter_lines():
|
|
100
|
+
if line:
|
|
101
|
+
yield jsonlib.loads(re.sub("^data:", "", line).strip())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@asynccontextmanager
|
|
105
|
+
async def a2a_client(agent_card: AgentCard, use_auth: bool = True) -> AsyncIterator[Client]:
|
|
106
|
+
async with httpx.AsyncClient(
|
|
107
|
+
headers=(
|
|
108
|
+
{"Authorization": f"Bearer {token}"}
|
|
109
|
+
if use_auth and (token := config.auth_manager.load_auth_token())
|
|
110
|
+
else {}
|
|
111
|
+
),
|
|
112
|
+
follow_redirects=True,
|
|
113
|
+
timeout=timedelta(hours=1).total_seconds(),
|
|
114
|
+
) as httpx_client:
|
|
115
|
+
yield ClientFactory(ClientConfig(httpx_client=httpx_client, use_client_preference=True)).create(card=agent_card)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@asynccontextmanager
|
|
119
|
+
async def openai_client() -> AsyncIterator[openai.AsyncOpenAI]:
|
|
120
|
+
async with Configuration().use_platform_client() as platform_client:
|
|
121
|
+
yield openai.AsyncOpenAI(
|
|
122
|
+
api_key=platform_client.headers.get("Authorization", "").removeprefix("Bearer ") or "dummy",
|
|
123
|
+
base_url=urllib.parse.urljoin(str(platform_client.base_url), urllib.parse.urljoin(API_BASE_URL, "openai")),
|
|
124
|
+
default_headers=platform_client.headers,
|
|
125
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
|
|
12
|
+
import rich.text
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import RenderResult
|
|
15
|
+
from rich.markdown import Heading, Markdown
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from typer.core import TyperGroup
|
|
18
|
+
|
|
19
|
+
from agentstack_cli.configuration import Configuration
|
|
20
|
+
from agentstack_cli.console import console, err_console
|
|
21
|
+
from agentstack_cli.utils import extract_messages, format_error
|
|
22
|
+
|
|
23
|
+
DEBUG = Configuration().debug
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _LeftAlignedHeading(Heading):
|
|
27
|
+
def __rich_console__(self, *args, **kwargs) -> RenderResult:
|
|
28
|
+
for elem in super().__rich_console__(*args, **kwargs):
|
|
29
|
+
if isinstance(elem, rich.text.Text):
|
|
30
|
+
elem.justify = "left"
|
|
31
|
+
yield elem
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
Markdown.elements["heading_open"] = _LeftAlignedHeading
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def create_table(*args, no_wrap: bool = True, **kwargs) -> Iterator[Table]:
|
|
39
|
+
table = Table(*args, **kwargs, box=None, pad_edge=False, width=console.width, show_header=True)
|
|
40
|
+
yield table
|
|
41
|
+
for column in table.columns:
|
|
42
|
+
column.no_wrap = no_wrap
|
|
43
|
+
column.overflow = "ellipsis"
|
|
44
|
+
assert isinstance(column.header, str)
|
|
45
|
+
column.header = column.header.upper()
|
|
46
|
+
|
|
47
|
+
if not table.rows:
|
|
48
|
+
table._render = lambda *args, **kwargs: [rich.text.Text("<No items found>", style="italic")]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AliasGroup(TyperGroup):
|
|
52
|
+
"""Taken from https://github.com/fastapi/typer/issues/132#issuecomment-2417492805"""
|
|
53
|
+
|
|
54
|
+
_CMD_SPLIT_P = re.compile(r" ?[,|] ?")
|
|
55
|
+
|
|
56
|
+
def get_command(self, ctx, cmd_name):
|
|
57
|
+
cmd_name = self._group_cmd_name(cmd_name)
|
|
58
|
+
return super().get_command(ctx, cmd_name)
|
|
59
|
+
|
|
60
|
+
def _group_cmd_name(self, default_name):
|
|
61
|
+
for cmd in self.commands.values():
|
|
62
|
+
name = cmd.name
|
|
63
|
+
if name and default_name in self._CMD_SPLIT_P.split(name):
|
|
64
|
+
return name
|
|
65
|
+
return default_name
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AsyncTyper(typer.Typer):
|
|
69
|
+
def __init__(self, *args, **kwargs):
|
|
70
|
+
super().__init__(*args, **kwargs, cls=AliasGroup)
|
|
71
|
+
|
|
72
|
+
def command(self, *args, **kwargs):
|
|
73
|
+
parent_decorator = super().command(*args, **kwargs)
|
|
74
|
+
|
|
75
|
+
def decorator(f):
|
|
76
|
+
@functools.wraps(f)
|
|
77
|
+
def wrapped_f(*args, **kwargs):
|
|
78
|
+
try:
|
|
79
|
+
if inspect.iscoroutinefunction(f):
|
|
80
|
+
return asyncio.run(f(*args, **kwargs))
|
|
81
|
+
else:
|
|
82
|
+
return f(*args, **kwargs)
|
|
83
|
+
except* Exception as ex:
|
|
84
|
+
is_connect_error = False
|
|
85
|
+
for exc_type, message in extract_messages(ex):
|
|
86
|
+
err_console.print(format_error(exc_type, message))
|
|
87
|
+
is_connect_error = is_connect_error or exc_type in ["ConnectionError", "ConnectError"]
|
|
88
|
+
err_console.print()
|
|
89
|
+
if is_connect_error:
|
|
90
|
+
err_console.hint(
|
|
91
|
+
"Start the Agent Stack platform using: [green]agentstack platform start[/green]. If that does not help, run [green]agentstack platform delete[/green] to clean up, then [green]agentstack platform start[/green] again."
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
err_console.hint(
|
|
95
|
+
"Are you having consistent problems? If so, try these troubleshooting steps: [green]agentstack platform delete[/green] to remove the platform, and [green]agentstack platform start[/green] to recreate it."
|
|
96
|
+
)
|
|
97
|
+
if DEBUG:
|
|
98
|
+
raise
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
|
|
101
|
+
parent_decorator(wrapped_f)
|
|
102
|
+
return f
|
|
103
|
+
|
|
104
|
+
return decorator
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import pathlib
|
|
5
|
+
import typing
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthToken(BaseModel):
|
|
13
|
+
access_token: str
|
|
14
|
+
token_type: str = "Bearer"
|
|
15
|
+
expires_in: int | None = None
|
|
16
|
+
refresh_token: str | None = None
|
|
17
|
+
scope: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthServer(BaseModel):
|
|
21
|
+
token: AuthToken | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Server(BaseModel):
|
|
25
|
+
authorization_servers: dict[str, AuthServer] = Field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Auth(BaseModel):
|
|
29
|
+
version: typing.Literal[1] = 1
|
|
30
|
+
servers: defaultdict[str, typing.Annotated[Server, Field(default_factory=Server)]] = Field(
|
|
31
|
+
default_factory=lambda: defaultdict(Server)
|
|
32
|
+
)
|
|
33
|
+
active_server: str | None = None
|
|
34
|
+
active_auth_server: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@typing.final
|
|
38
|
+
class AuthManager:
|
|
39
|
+
def __init__(self, config_path: pathlib.Path):
|
|
40
|
+
self._auth_path = config_path
|
|
41
|
+
self._auth = self._load()
|
|
42
|
+
|
|
43
|
+
def _load(self) -> Auth:
|
|
44
|
+
if not self._auth_path.exists():
|
|
45
|
+
return Auth()
|
|
46
|
+
return Auth.model_validate_json(self._auth_path.read_bytes())
|
|
47
|
+
|
|
48
|
+
def _save(self) -> None:
|
|
49
|
+
self._auth_path.write_text(self._auth.model_dump_json(indent=2))
|
|
50
|
+
|
|
51
|
+
def save_auth_token(self, server: str, auth_server: str | None = None, token: dict[str, Any] | None = None) -> None:
|
|
52
|
+
if auth_server is not None and token is not None:
|
|
53
|
+
self._auth.servers[server].authorization_servers[auth_server] = AuthServer(token=AuthToken(**token))
|
|
54
|
+
else:
|
|
55
|
+
self._auth.servers[server] # touch
|
|
56
|
+
self._save()
|
|
57
|
+
|
|
58
|
+
def load_auth_token(self) -> str | None:
|
|
59
|
+
active_res = self._auth.active_server
|
|
60
|
+
active_auth_server = self._auth.active_auth_server
|
|
61
|
+
if not active_res or not active_auth_server:
|
|
62
|
+
return None
|
|
63
|
+
server = self._auth.servers.get(active_res)
|
|
64
|
+
if not server:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
auth_server = server.authorization_servers.get(active_auth_server)
|
|
68
|
+
if not auth_server or not auth_server.token:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
return auth_server.token.access_token
|
|
72
|
+
|
|
73
|
+
def clear_auth_token(self, all: bool = False) -> None:
|
|
74
|
+
if all:
|
|
75
|
+
self._auth.servers = defaultdict(Server)
|
|
76
|
+
else:
|
|
77
|
+
if self._auth.active_server and self._auth.active_auth_server:
|
|
78
|
+
del self._auth.servers[self._auth.active_server].authorization_servers[self._auth.active_auth_server]
|
|
79
|
+
if self._auth.active_server and not self._auth.servers[self._auth.active_server].authorization_servers:
|
|
80
|
+
del self._auth.servers[self._auth.active_server]
|
|
81
|
+
self._auth.active_server = None
|
|
82
|
+
self._auth.active_auth_server = None
|
|
83
|
+
self._save()
|
|
84
|
+
|
|
85
|
+
def get_server(self, server: str) -> Server | None:
|
|
86
|
+
return self._auth.servers.get(server)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def servers(self) -> list[str]:
|
|
90
|
+
return list(self._auth.servers.keys())
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def active_server(self) -> str | None:
|
|
94
|
+
return self._auth.active_server
|
|
95
|
+
|
|
96
|
+
@active_server.setter
|
|
97
|
+
def active_server(self, server: str | None) -> None:
|
|
98
|
+
if server is not None and server not in self._auth.servers:
|
|
99
|
+
raise ValueError(f"Server {server} not found")
|
|
100
|
+
self._auth.active_server = server
|
|
101
|
+
self._save()
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def active_auth_server(self) -> str | None:
|
|
105
|
+
return self._auth.active_auth_server
|
|
106
|
+
|
|
107
|
+
@active_auth_server.setter
|
|
108
|
+
def active_auth_server(self, auth_server: str | None) -> None:
|
|
109
|
+
if auth_server is not None and (
|
|
110
|
+
self._auth.active_server not in self._auth.servers
|
|
111
|
+
or auth_server not in self._auth.servers[self._auth.active_server].authorization_servers
|
|
112
|
+
):
|
|
113
|
+
raise ValueError(f"Auth server {auth_server} not found in active server")
|
|
114
|
+
self._auth.active_auth_server = auth_server
|
|
115
|
+
self._save()
|