agentstack-cli 0.4.1rc1__tar.gz → 0.4.2__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 (27) hide show
  1. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/PKG-INFO +1 -1
  2. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/pyproject.toml +1 -1
  3. agentstack_cli-0.4.2/src/agentstack_cli/__init__.py +119 -0
  4. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/api.py +28 -14
  5. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/async_typer.py +2 -1
  6. agentstack_cli-0.4.2/src/agentstack_cli/auth_manager.py +241 -0
  7. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/commands/agent.py +123 -145
  8. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/commands/build.py +74 -48
  9. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/commands/mcp.py +12 -3
  10. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/commands/model.py +28 -3
  11. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/commands/platform/__init__.py +5 -5
  12. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/commands/platform/base_driver.py +3 -2
  13. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/commands/platform/wsl_driver.py +7 -2
  14. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/commands/self.py +8 -4
  15. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/commands/server.py +40 -5
  16. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/configuration.py +1 -1
  17. agentstack_cli-0.4.2/src/agentstack_cli/data/helm-chart.tgz +0 -0
  18. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/utils.py +61 -5
  19. agentstack_cli-0.4.1rc1/src/agentstack_cli/__init__.py +0 -77
  20. agentstack_cli-0.4.1rc1/src/agentstack_cli/auth_manager.py +0 -126
  21. agentstack_cli-0.4.1rc1/src/agentstack_cli/data/helm-chart.tgz +0 -0
  22. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/README.md +0 -0
  23. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/commands/__init__.py +0 -0
  24. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/commands/platform/istio.py +0 -0
  25. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/commands/platform/lima_driver.py +0 -0
  26. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/src/agentstack_cli/console.py +0 -0
  27. {agentstack_cli-0.4.1rc1 → agentstack_cli-0.4.2}/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.4.1rc1
3
+ Version: 0.4.2
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.4.1-rc1"
3
+ version = "0.4.2"
4
4
  description = "Agent Stack CLI"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "IBM Corp." }]
@@ -0,0 +1,119 @@
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 AliasGroup, 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
+
24
+ class RootHelpGroup(AliasGroup):
25
+ def get_help(self, ctx):
26
+ return """\
27
+ Usage: agentstack [OPTIONS] COMMAND [ARGS]...
28
+
29
+ ╭─ Getting Started ──────────────────────────────────────────────────────────╮
30
+ │ ui Launch the web interface │
31
+ │ list View all available agents │
32
+ │ run Run an agent interactively │
33
+ ╰────────────────────────────────────────────────────────────────────────────╯
34
+
35
+ ╭─ Agent Management ─────────────────────────────────────────────────────────╮
36
+ │ add Install an agent (Docker, GitHub) │
37
+ │ remove Uninstall an agent │
38
+ │ update Update an agent │
39
+ │ info Show agent details │
40
+ │ logs Stream agent execution logs │
41
+ │ env Manage agent environment variables │
42
+ │ build Build an agent remotely │
43
+ │ client-side-build Build an agent container image locally │
44
+ ╰────────────────────────────────────────────────────────────────────────────╯
45
+
46
+ ╭─ Platform & Configuration ─────────────────────────────────────────────────╮
47
+ │ model Configure 15+ LLM providers │
48
+ │ platform Start, stop, or delete local platform │
49
+ │ server Connect to remote Agent Stack servers │
50
+ │ self version Show Agent Stack CLI and Platform version │
51
+ │ self upgrade Upgrade Agent Stack CLI and Platform │
52
+ │ self uninstall Uninstall Agent Stack CLI and Platform │
53
+ ╰────────────────────────────────────────────────────────────────────────────╯
54
+
55
+ ╭─ Options ──────────────────────────────────────────────────────────────────╮
56
+ │ --help Show this help message │
57
+ │ --show-completion Show tab completion script │
58
+ │ --install-completion Enable tab completion for commands │
59
+ ╰────────────────────────────────────────────────────────────────────────────╯
60
+ """
61
+
62
+
63
+ app = AsyncTyper(no_args_is_help=True, cls=RootHelpGroup)
64
+ app.add_typer(agentstack_cli.commands.model.app, name="model", no_args_is_help=True, help="Manage model providers.")
65
+ app.add_typer(agentstack_cli.commands.agent.app, name="agent", no_args_is_help=True, help="Manage agents.")
66
+ app.add_typer(
67
+ agentstack_cli.commands.platform.app, name="platform", no_args_is_help=True, help="Manage Agent Stack platform."
68
+ )
69
+ app.add_typer(
70
+ agentstack_cli.commands.mcp.app, name="mcp", no_args_is_help=True, help="Manage MCP servers and toolkits."
71
+ )
72
+ app.add_typer(agentstack_cli.commands.build.app, name="", no_args_is_help=True, help="Build agent images.")
73
+ app.add_typer(
74
+ agentstack_cli.commands.server.app,
75
+ name="server",
76
+ no_args_is_help=True,
77
+ help="Manage Agent Stack servers and authentication.",
78
+ )
79
+ app.add_typer(
80
+ agentstack_cli.commands.self.app,
81
+ name="self",
82
+ no_args_is_help=True,
83
+ help="Manage Agent Stack installation.",
84
+ hidden=True,
85
+ )
86
+
87
+
88
+ agent_alias = deepcopy(agentstack_cli.commands.agent.app)
89
+ for cmd in agent_alias.registered_commands:
90
+ cmd.rich_help_panel = "Agent commands"
91
+
92
+ app.add_typer(agent_alias, name="", no_args_is_help=True)
93
+
94
+
95
+ @app.command("version")
96
+ async def version(
97
+ verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
98
+ ):
99
+ """Print version of the Agent Stack CLI."""
100
+ import agentstack_cli.commands.self
101
+
102
+ await agentstack_cli.commands.self.version(verbose=verbose)
103
+
104
+
105
+ @app.command("ui")
106
+ async def ui():
107
+ """Launch the graphical interface."""
108
+ import webbrowser
109
+
110
+ import agentstack_cli.commands.model
111
+
112
+ await agentstack_cli.commands.model.ensure_llm_provider()
113
+ webbrowser.open(
114
+ "http://localhost:8334"
115
+ ) # TODO: This always opens the local UI, how to open the UI of a logged in server instead?
116
+
117
+
118
+ if __name__ == "__main__":
119
+ app()
@@ -1,17 +1,18 @@
1
1
  # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
2
  # SPDX-License-Identifier: Apache-2.0
3
-
3
+ import json
4
4
  import re
5
5
  import urllib
6
6
  import urllib.parse
7
7
  from collections.abc import AsyncIterator
8
8
  from contextlib import asynccontextmanager
9
9
  from datetime import timedelta
10
+ from textwrap import indent
10
11
  from typing import Any
11
12
 
12
13
  import httpx
13
14
  import openai
14
- from a2a.client import Client, ClientConfig, ClientFactory
15
+ from a2a.client import A2AClientHTTPError, Client, ClientConfig, ClientFactory
15
16
  from a2a.types import AgentCard
16
17
  from httpx import HTTPStatusError
17
18
  from httpx._types import RequestFiles
@@ -43,7 +44,7 @@ async def api_request(
43
44
  timeout=60,
44
45
  headers=(
45
46
  {"Authorization": f"Bearer {token}"}
46
- if use_auth and (token := config.auth_manager.load_auth_token())
47
+ if use_auth and (token := await config.auth_manager.load_auth_token())
47
48
  else {}
48
49
  ),
49
50
  )
@@ -82,7 +83,7 @@ async def api_stream(
82
83
  timeout=timedelta(hours=1).total_seconds(),
83
84
  headers=(
84
85
  {"Authorization": f"Bearer {token}"}
85
- if use_auth and (token := config.auth_manager.load_auth_token())
86
+ if use_auth and (token := await config.auth_manager.load_auth_token())
86
87
  else {}
87
88
  ),
88
89
  ) as response,
@@ -103,16 +104,29 @@ async def api_stream(
103
104
 
104
105
  @asynccontextmanager
105
106
  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)
107
+ try:
108
+ async with httpx.AsyncClient(
109
+ headers=(
110
+ {"Authorization": f"Bearer {token}"}
111
+ if use_auth and (token := await config.auth_manager.load_auth_token())
112
+ else {}
113
+ ),
114
+ follow_redirects=True,
115
+ timeout=timedelta(hours=1).total_seconds(),
116
+ ) as httpx_client:
117
+ yield ClientFactory(ClientConfig(httpx_client=httpx_client, use_client_preference=True)).create(
118
+ card=agent_card
119
+ )
120
+ except A2AClientHTTPError as ex:
121
+ card_data = json.dumps(
122
+ agent_card.model_dump(include={"url", "additional_interfaces", "preferred_transport"}), indent=2
123
+ )
124
+ raise RuntimeError(
125
+ f"The agent is not reachable, please check that the agent card is configured properly.\n"
126
+ f"Agent connection info:\n{indent(card_data, prefix=' ')}\n"
127
+ "Full Error:\n"
128
+ f"{indent(str(ex), prefix=' ')}"
129
+ ) from ex
116
130
 
117
131
 
118
132
  @asynccontextmanager
@@ -67,7 +67,8 @@ class AliasGroup(TyperGroup):
67
67
 
68
68
  class AsyncTyper(typer.Typer):
69
69
  def __init__(self, *args, **kwargs):
70
- super().__init__(*args, **kwargs, cls=AliasGroup)
70
+ kwargs["cls"] = kwargs.get("cls", AliasGroup)
71
+ super().__init__(*args, **kwargs)
71
72
 
72
73
  def command(self, *args, **kwargs):
73
74
  parent_decorator = super().command(*args, **kwargs)
@@ -0,0 +1,241 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ import pathlib
4
+ import time
5
+ import typing
6
+ from collections import defaultdict
7
+ from typing import Any
8
+
9
+ import httpx
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ class AuthToken(BaseModel):
14
+ access_token: str
15
+ token_type: str = "Bearer"
16
+ expires_in: int | None = None
17
+ expires_at: int | None = None
18
+ refresh_token: str | None = None
19
+ scope: str | None = None
20
+
21
+
22
+ class AuthServer(BaseModel):
23
+ client_id: str = "df82a687-d647-4247-838b-7080d7d83f6c" # Backwards compatibility default
24
+ client_secret: str | None = None
25
+ token: AuthToken | None = None
26
+ registration_token: str | None = None
27
+
28
+
29
+ class Server(BaseModel):
30
+ authorization_servers: dict[str, AuthServer] = Field(default_factory=dict)
31
+
32
+
33
+ class Auth(BaseModel):
34
+ version: typing.Literal[1] = 1
35
+ servers: defaultdict[str, typing.Annotated[Server, Field(default_factory=Server)]] = Field(
36
+ default_factory=lambda: defaultdict(Server)
37
+ )
38
+ active_server: str | None = None
39
+ active_auth_server: str | None = None
40
+
41
+
42
+ @typing.final
43
+ class AuthManager:
44
+ def __init__(self, config_path: pathlib.Path):
45
+ self._auth_path = config_path
46
+ self._auth = self._load()
47
+
48
+ def _load(self) -> Auth:
49
+ if not self._auth_path.exists():
50
+ return Auth()
51
+ return Auth.model_validate_json(self._auth_path.read_bytes())
52
+
53
+ def _save(self) -> None:
54
+ self._auth_path.write_text(self._auth.model_dump_json(indent=2))
55
+
56
+ def save_auth_token(
57
+ self,
58
+ server: str,
59
+ auth_server: str | None = None,
60
+ client_id: str | None = None,
61
+ client_secret: str | None = None,
62
+ token: dict[str, Any] | None = None,
63
+ registration_token: str | None = None,
64
+ ) -> None:
65
+ if auth_server is not None and client_id is not None and token is not None:
66
+ if token["access_token"]:
67
+ usetimestamp = int(time.time()) + int(token["expires_in"])
68
+ token["expires_at"] = usetimestamp
69
+ self._auth.servers[server].authorization_servers[auth_server] = AuthServer(
70
+ client_id=client_id,
71
+ client_secret=client_secret,
72
+ token=AuthToken(**token),
73
+ registration_token=registration_token,
74
+ )
75
+ else:
76
+ self._auth.servers[server] # touch
77
+ self._save()
78
+
79
+ async def exchange_refresh_token(self, auth_server: str, token: AuthToken) -> dict[str, Any] | None:
80
+ """
81
+ This method exchanges a refresh token for a new access token.
82
+ """
83
+ async with httpx.AsyncClient(headers={"Accept": "application/json"}) as client:
84
+ resp = None
85
+ try:
86
+ resp = await client.get(f"{auth_server}/.well-known/openid-configuration")
87
+ resp.raise_for_status()
88
+ oidc = resp.json()
89
+ except Exception as e:
90
+ if resp:
91
+ error_details = resp.json()
92
+ print(f"error: {error_details['error']} error description: {error_details['error_description']}")
93
+ raise RuntimeError(f"OIDC discovery failed: {e}") from e
94
+
95
+ token_endpoint = oidc["token_endpoint"]
96
+ try:
97
+ client_id = (
98
+ self._auth.servers[self._auth.active_server or ""].authorization_servers[auth_server].client_id
99
+ )
100
+ client_secret = (
101
+ self._auth.servers[self._auth.active_server or ""].authorization_servers[auth_server].client_secret
102
+ )
103
+ resp = await client.post(
104
+ f"{token_endpoint}",
105
+ data={
106
+ "grant_type": "refresh_token",
107
+ "refresh_token": token.refresh_token,
108
+ "scope": token.scope,
109
+ "client_id": client_id,
110
+ "client_secret": client_secret,
111
+ },
112
+ )
113
+ resp.raise_for_status()
114
+ new_token = resp.json()
115
+ except Exception as e:
116
+ if resp:
117
+ error_details = resp.json()
118
+ print(f"error: {error_details['error']} error description: {error_details['error_description']}")
119
+ raise RuntimeError(f"Failed to refresh token: {e}") from e
120
+ self.save_auth_token(
121
+ self._auth.active_server or "",
122
+ self._auth.active_auth_server or "",
123
+ self._auth.servers[self._auth.active_server or ""].authorization_servers[auth_server].client_id or "",
124
+ self._auth.servers[self._auth.active_server or ""].authorization_servers[auth_server].client_secret
125
+ or "",
126
+ token=new_token,
127
+ )
128
+ return new_token
129
+
130
+ async def load_auth_token(self) -> str | None:
131
+ active_res = self._auth.active_server
132
+ active_auth_server = self._auth.active_auth_server
133
+ if not active_res or not active_auth_server:
134
+ return None
135
+ server = self._auth.servers.get(active_res)
136
+ if not server:
137
+ return None
138
+
139
+ auth_server = server.authorization_servers.get(active_auth_server)
140
+ if not auth_server or not auth_server.token:
141
+ return None
142
+
143
+ if (auth_server.token.expires_at or 0) - 60 < time.time():
144
+ new_token = await self.exchange_refresh_token(active_auth_server, auth_server.token)
145
+ if new_token:
146
+ return new_token["access_token"]
147
+ return None
148
+
149
+ return auth_server.token.access_token
150
+
151
+ async def deregister_client(self, auth_server, client_id, registration_token) -> None:
152
+ async with httpx.AsyncClient(headers={"Accept": "application/json"}) as client:
153
+ resp = None
154
+ try:
155
+ resp = await client.get(f"{auth_server}/.well-known/openid-configuration")
156
+ resp.raise_for_status()
157
+ oidc = resp.json()
158
+ registration_endpoint = oidc["registration_endpoint"]
159
+ except Exception as e:
160
+ if resp:
161
+ error_details = resp.json()
162
+ print(f"error: {error_details['error']} error description: {error_details['error_description']}")
163
+ raise RuntimeError(f"OIDC discovery failed: {e}") from e
164
+
165
+ try:
166
+ if client_id is not None and client_id != "" and registration_token is not None:
167
+ headers = {"authorization": f"bearer {registration_token}"}
168
+ resp = await client.delete(f"{registration_endpoint}/{client_id}", headers=headers)
169
+ resp.raise_for_status()
170
+
171
+ except Exception as e:
172
+ if resp:
173
+ error_details = resp.json()
174
+ print(f"error: {error_details['error']} error description: {error_details['error_description']}")
175
+ raise RuntimeError(f"Dynamic client de-registration failed. {e}") from e
176
+
177
+ async def clear_auth_token(self, all: bool = False) -> None:
178
+ if all:
179
+ for server in self._auth.servers:
180
+ for auth_server in self._auth.servers[server].authorization_servers:
181
+ await self.deregister_client(
182
+ auth_server,
183
+ self._auth.servers[server].authorization_servers[auth_server].client_id,
184
+ self._auth.servers[server].authorization_servers[auth_server].registration_token,
185
+ )
186
+
187
+ self._auth.servers = defaultdict(Server)
188
+ else:
189
+ if self._auth.active_server and self._auth.active_auth_server:
190
+ if (
191
+ self._auth.servers[self._auth.active_server]
192
+ .authorization_servers[self._auth.active_auth_server]
193
+ .client_id
194
+ ):
195
+ await self.deregister_client(
196
+ self._auth.active_auth_server,
197
+ self._auth.servers[self._auth.active_server]
198
+ .authorization_servers[self._auth.active_auth_server]
199
+ .client_id,
200
+ self._auth.servers[self._auth.active_server]
201
+ .authorization_servers[self._auth.active_auth_server]
202
+ .registration_token,
203
+ )
204
+ del self._auth.servers[self._auth.active_server].authorization_servers[self._auth.active_auth_server]
205
+ if self._auth.active_server and not self._auth.servers[self._auth.active_server].authorization_servers:
206
+ del self._auth.servers[self._auth.active_server]
207
+ self._auth.active_server = None
208
+ self._auth.active_auth_server = None
209
+ self._save()
210
+
211
+ def get_server(self, server: str) -> Server | None:
212
+ return self._auth.servers.get(server)
213
+
214
+ @property
215
+ def servers(self) -> list[str]:
216
+ return list(self._auth.servers.keys())
217
+
218
+ @property
219
+ def active_server(self) -> str | None:
220
+ return self._auth.active_server
221
+
222
+ @active_server.setter
223
+ def active_server(self, server: str | None) -> None:
224
+ if server is not None and server not in self._auth.servers:
225
+ raise ValueError(f"Server {server} not found")
226
+ self._auth.active_server = server
227
+ self._save()
228
+
229
+ @property
230
+ def active_auth_server(self) -> str | None:
231
+ return self._auth.active_auth_server
232
+
233
+ @active_auth_server.setter
234
+ def active_auth_server(self, auth_server: str | None) -> None:
235
+ if auth_server is not None and (
236
+ self._auth.active_server not in self._auth.servers
237
+ or auth_server not in self._auth.servers[self._auth.active_server].authorization_servers
238
+ ):
239
+ raise ValueError(f"Auth server {auth_server} not found in active server")
240
+ self._auth.active_auth_server = auth_server
241
+ self._save()