agentstack-cli 0.4.3rc3__tar.gz → 0.5.0__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.4.3rc3 → agentstack_cli-0.5.0}/PKG-INFO +1 -1
  2. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/pyproject.toml +1 -1
  3. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/__init__.py +22 -3
  4. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/api.py +24 -0
  5. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/commands/agent.py +186 -15
  6. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/commands/build.py +6 -5
  7. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/commands/model.py +2 -1
  8. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/commands/platform/base_driver.py +0 -25
  9. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/commands/self.py +4 -1
  10. agentstack_cli-0.5.0/src/agentstack_cli/commands/user.py +92 -0
  11. agentstack_cli-0.5.0/src/agentstack_cli/data/helm-chart.tgz +0 -0
  12. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/utils.py +10 -5
  13. agentstack_cli-0.4.3rc3/src/agentstack_cli/data/helm-chart.tgz +0 -0
  14. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/README.md +0 -0
  15. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/async_typer.py +0 -0
  16. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/auth_manager.py +0 -0
  17. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/commands/__init__.py +0 -0
  18. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/commands/mcp.py +0 -0
  19. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/commands/platform/__init__.py +0 -0
  20. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/commands/platform/istio.py +0 -0
  21. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/commands/platform/lima_driver.py +0 -0
  22. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/commands/platform/wsl_driver.py +0 -0
  23. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/commands/server.py +0 -0
  24. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/configuration.py +0 -0
  25. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/src/agentstack_cli/console.py +0 -0
  26. {agentstack_cli-0.4.3rc3 → agentstack_cli-0.5.0}/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.3rc3
3
+ Version: 0.5.0
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.3-rc3"
3
+ version = "0.5.0"
4
4
  description = "Agent Stack CLI"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "IBM Corp." }]
@@ -2,6 +2,7 @@
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import logging
5
+ import re
5
6
  import typing
6
7
  from copy import deepcopy
7
8
 
@@ -14,6 +15,7 @@ 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
18
+ import agentstack_cli.commands.user
17
19
  from agentstack_cli.async_typer import AliasGroup, AsyncTyper
18
20
  from agentstack_cli.configuration import Configuration
19
21
 
@@ -47,6 +49,7 @@ Usage: agentstack [OPTIONS] COMMAND [ARGS]...
47
49
  │ model Configure 15+ LLM providers │
48
50
  │ platform Start, stop, or delete local platform │
49
51
  │ server Connect to remote Agent Stack servers │
52
+ │ user Manage users and roles │
50
53
  │ self version Show Agent Stack CLI and Platform version │
51
54
  │ self upgrade Upgrade Agent Stack CLI and Platform │
52
55
  │ self uninstall Uninstall Agent Stack CLI and Platform │
@@ -83,6 +86,12 @@ app.add_typer(
83
86
  help="Manage Agent Stack installation.",
84
87
  hidden=True,
85
88
  )
89
+ app.add_typer(
90
+ agentstack_cli.commands.user.app,
91
+ name="user",
92
+ no_args_is_help=True,
93
+ help="Manage users.",
94
+ )
86
95
 
87
96
 
88
97
  agent_alias = deepcopy(agentstack_cli.commands.agent.app)
@@ -110,9 +119,19 @@ async def ui():
110
119
  import agentstack_cli.commands.model
111
120
 
112
121
  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?
122
+
123
+ config = Configuration()
124
+ active_server = config.auth_manager.active_server
125
+
126
+ if active_server:
127
+ if re.search(r"(localhost|127\.0\.0\.1):8333", active_server):
128
+ ui_url = re.sub(r":8333", ":8334", active_server)
129
+ else:
130
+ ui_url = active_server
131
+ else:
132
+ ui_url = "http://localhost:8334"
133
+
134
+ webbrowser.open(ui_url)
116
135
 
117
136
 
118
137
  if __name__ == "__main__":
@@ -1,6 +1,7 @@
1
1
  # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
  import json
4
+ import logging
4
5
  import re
5
6
  import urllib
6
7
  import urllib.parse
@@ -12,6 +13,7 @@ from typing import Any
12
13
 
13
14
  import httpx
14
15
  import openai
16
+ import pydantic
15
17
  from a2a.client import A2AClientHTTPError, Client, ClientConfig, ClientFactory
16
18
  from a2a.types import AgentCard
17
19
  from httpx import HTTPStatusError
@@ -20,6 +22,8 @@ from httpx._types import RequestFiles
20
22
  from agentstack_cli import configuration
21
23
  from agentstack_cli.configuration import Configuration
22
24
 
25
+ logger = logging.getLogger(__name__)
26
+
23
27
  config = Configuration()
24
28
 
25
29
  API_BASE_URL = "api/v1/"
@@ -102,6 +106,26 @@ async def api_stream(
102
106
  yield jsonlib.loads(re.sub("^data:", "", line).strip())
103
107
 
104
108
 
109
+ async def fetch_server_version() -> str | None:
110
+ """Fetch server version from OpenAPI schema."""
111
+
112
+ class OpenAPIInfo(pydantic.BaseModel):
113
+ version: str
114
+
115
+ class OpenAPISchema(pydantic.BaseModel):
116
+ info: OpenAPIInfo
117
+
118
+ try:
119
+ response = await api_request("GET", "openapi.json", use_auth=False)
120
+ if not response:
121
+ return None
122
+ schema = OpenAPISchema.model_validate(response)
123
+ return schema.info.version
124
+ except Exception as e:
125
+ logger.warning("Failed to fetch server version: %s", e)
126
+ return None
127
+
128
+
105
129
  @asynccontextmanager
106
130
  async def a2a_client(agent_card: AgentCard, use_auth: bool = True) -> AsyncIterator[Client]:
107
131
  try:
@@ -61,7 +61,7 @@ from agentstack_sdk.a2a.extensions.common.form import (
61
61
  TextField,
62
62
  TextFieldValue,
63
63
  )
64
- from agentstack_sdk.platform import BuildState, ModelProvider, Provider
64
+ from agentstack_sdk.platform import BuildState, File, ModelProvider, Provider, UserFeedback
65
65
  from agentstack_sdk.platform.context import Context, ContextPermissions, ContextToken, Permissions
66
66
  from agentstack_sdk.platform.model_provider import ModelCapability
67
67
  from InquirerPy import inquirer
@@ -152,12 +152,50 @@ configuration = Configuration()
152
152
 
153
153
  @app.command("add")
154
154
  async def add_agent(
155
- location: typing.Annotated[str, typer.Argument(help="Agent location (public docker image or github url)")],
155
+ location: typing.Annotated[
156
+ str | None, typer.Argument(help="Agent location (public docker image or github url)")
157
+ ] = None,
156
158
  dockerfile: typing.Annotated[str | None, typer.Option(help="Use custom dockerfile path")] = None,
157
159
  verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
158
160
  yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
159
161
  ) -> None:
160
162
  """Add a docker image or GitHub repository [aliases: install]"""
163
+ if location is None:
164
+ repo_input = (
165
+ await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
166
+ message="Enter GitHub repository (owner/repo or full URL):",
167
+ ).execute_async()
168
+ or ""
169
+ )
170
+
171
+ match = re.search(r"^(?:(?:https?://)?(?:www\.)?github\.com/)?([^/]+)/([^/?&]+)", repo_input)
172
+ if not match:
173
+ raise ValueError(f"Invalid GitHub URL format: {repo_input}. Expected 'owner/repo' or a full GitHub URL.")
174
+
175
+ owner, repo = match.group(1), match.group(2).removesuffix(".git")
176
+
177
+ async with httpx.AsyncClient() as client:
178
+ response = await client.get(
179
+ f"https://api.github.com/repos/{owner}/{repo}/tags",
180
+ headers={"Accept": "application/vnd.github.v3+json"},
181
+ )
182
+ tags = [tag["name"] for tag in response.json()] if response.status_code == 200 else []
183
+
184
+ if tags:
185
+ selected_tag = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
186
+ message="Select a tag to use:",
187
+ choices=tags,
188
+ ).execute_async()
189
+ else:
190
+ selected_tag = (
191
+ await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
192
+ message="Enter tag to use:",
193
+ ).execute_async()
194
+ or "main"
195
+ )
196
+
197
+ location = f"https://github.com/{owner}/{repo}@{selected_tag}"
198
+
161
199
  url = announce_server_action(f"Installing agent '{location}' for")
162
200
  await confirm_server_action("Proceed with installing this agent on", url=url, yes=yes)
163
201
  with verbosity(verbose):
@@ -182,9 +220,11 @@ async def add_agent(
182
220
  @app.command("update")
183
221
  async def update_agent(
184
222
  search_path: typing.Annotated[
185
- str, typer.Argument(..., help="Short ID, agent name or part of the provider location of agent to replace")
186
- ],
187
- location: typing.Annotated[str, typer.Argument(help="Agent location (public docker image or github url)")],
223
+ str | None, typer.Argument(help="Short ID, agent name or part of the provider location of agent to replace")
224
+ ] = None,
225
+ location: typing.Annotated[
226
+ str | None, typer.Argument(help="Agent location (public docker image or github url)")
227
+ ] = None,
188
228
  dockerfile: typing.Annotated[str | None, typer.Option(help="Use custom dockerfile path")] = None,
189
229
  verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
190
230
  yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
@@ -192,7 +232,58 @@ async def update_agent(
192
232
  """Upgrade agent to a newer docker image or build from GitHub repository"""
193
233
  with verbosity(verbose):
194
234
  async with configuration.use_platform_client():
195
- provider = select_provider(search_path, providers=await Provider.list())
235
+ providers = await Provider.list()
236
+
237
+ if search_path is None:
238
+ if not providers:
239
+ console.error("No agents found. Add an agent first using 'agentstack agent add'.")
240
+ sys.exit(1)
241
+
242
+ provider_choices = [
243
+ Choice(value=p, name=f"{p.agent_card.name} ({ProviderUtils.short_location(p)})") for p in providers
244
+ ]
245
+ provider = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
246
+ message="Select an agent to update:",
247
+ choices=provider_choices,
248
+ ).execute_async()
249
+ if not provider:
250
+ console.error("No agent selected. Exiting.")
251
+ sys.exit(1)
252
+ else:
253
+ provider = select_provider(search_path, providers=providers)
254
+
255
+ if location is None and is_github_url(provider.source):
256
+ match = re.search(r"^(?:(?:https?://)?(?:www\.)?github\.com/)?([^/]+)/([^/@?&]+)", provider.source)
257
+ if match:
258
+ owner, repo = match.group(1), match.group(2).removesuffix(".git")
259
+
260
+ async with httpx.AsyncClient() as client:
261
+ response = await client.get(
262
+ f"https://api.github.com/repos/{owner}/{repo}/tags",
263
+ headers={"Accept": "application/vnd.github.v3+json"},
264
+ )
265
+ tags = [tag["name"] for tag in response.json()] if response.status_code == 200 else []
266
+
267
+ if tags:
268
+ selected_tag = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
269
+ message="Select a new tag to use:",
270
+ choices=tags,
271
+ ).execute_async()
272
+ if selected_tag:
273
+ location = f"https://github.com/{owner}/{repo}@{selected_tag}"
274
+
275
+ if location is None:
276
+ location = (
277
+ await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
278
+ message="Enter new agent location (public docker image or github url):",
279
+ default=provider.source,
280
+ ).execute_async()
281
+ or ""
282
+ )
283
+
284
+ if not location:
285
+ console.error("No location provided. Exiting.")
286
+ sys.exit(1)
196
287
 
197
288
  url = announce_server_action(f"Upgrading agent from '{provider.source}' to {location}")
198
289
  await confirm_server_action("Proceed with upgrading agent on", url=url, yes=yes)
@@ -560,10 +651,13 @@ async def _run_agent(
560
651
  match part.root.file:
561
652
  case FileWithBytes(bytes=bytes_str):
562
653
  full_path.write_bytes(base64.b64decode(bytes_str))
563
- case FileWithUri(uri=uri): # TODO process platform file uri
564
- async with httpx.AsyncClient() as httpx_client:
565
- resp = await httpx_client.get(uri)
566
- full_path.write_bytes(base64.b64decode(resp.content))
654
+ case FileWithUri(uri=uri):
655
+ if uri.startswith("agentstack://"):
656
+ async with File.load_content(uri.removeprefix("agentstack://")) as file:
657
+ full_path.write_bytes(file.content)
658
+ else:
659
+ async with httpx.AsyncClient() as httpx_client:
660
+ full_path.write_bytes((await httpx_client.get(uri)).content)
567
661
  console.print(f"📁 Saved {full_path}")
568
662
  case TextPart(text=text):
569
663
  full_path.write_text(text)
@@ -770,24 +864,41 @@ def _create_input_handler(
770
864
  @app.command("run")
771
865
  async def run_agent(
772
866
  search_path: typing.Annotated[
773
- str, typer.Argument(..., help="Short ID, agent name or part of the provider location")
774
- ],
867
+ str | None,
868
+ typer.Argument(
869
+ help="Short ID, agent name or part of the provider location",
870
+ ),
871
+ ] = None,
775
872
  input: typing.Annotated[
776
873
  str | None,
777
874
  typer.Argument(
778
- default_factory=lambda: None if sys.stdin.isatty() else sys.stdin.read(),
779
875
  help="Agent input as text or JSON",
780
876
  ),
781
- ],
877
+ ] = None,
782
878
  dump_files: typing.Annotated[
783
879
  Path | None, typer.Option(help="Folder path to save any files returned by the agent")
784
880
  ] = None,
785
881
  ) -> None:
786
882
  """Run an agent."""
787
- announce_server_action(f"Running agent '{search_path}' on")
883
+ if search_path is not None and input is None and sys.stdin.isatty():
884
+ input = sys.stdin.read()
788
885
  async with configuration.use_platform_client():
789
886
  providers = await Provider.list()
790
887
  await ensure_llm_provider()
888
+
889
+ if search_path is None:
890
+ if not providers:
891
+ err_console.error("No agents found. Add an agent first using 'agentstack agent add'.")
892
+ sys.exit(1)
893
+ search_path = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
894
+ message="Select an agent to run:",
895
+ choices=[provider.agent_card.name for provider in providers],
896
+ ).execute_async()
897
+ if search_path is None:
898
+ err_console.error("No agent selected. Exiting.")
899
+ sys.exit(1)
900
+
901
+ announce_server_action(f"Running agent '{search_path}' on")
791
902
  provider = select_provider(search_path, providers=providers)
792
903
 
793
904
  context = await Context.create(
@@ -1053,3 +1164,63 @@ async def remove_env(
1053
1164
  provider = select_provider(search_path, await Provider.list())
1054
1165
  await provider.update_variables(variables=dict.fromkeys(env))
1055
1166
  await _list_env(provider)
1167
+
1168
+
1169
+ feedback_app = AsyncTyper()
1170
+ app.add_typer(feedback_app, name="feedback", help="Manage user feedback for your agents", no_args_is_help=True)
1171
+
1172
+
1173
+ @feedback_app.command("list")
1174
+ async def list_feedback(
1175
+ search_path: typing.Annotated[
1176
+ str | None, typer.Argument(help="Short ID, agent name or part of the provider location")
1177
+ ] = None,
1178
+ limit: typing.Annotated[int, typer.Option("--limit", help="Number of results per page [default: 50]")] = 50,
1179
+ after_cursor: typing.Annotated[str | None, typer.Option("--after", help="Cursor for pagination")] = None,
1180
+ ):
1181
+ """List your agent feedback"""
1182
+
1183
+ announce_server_action("Listing feedback on")
1184
+
1185
+ provider_id = None
1186
+
1187
+ async with configuration.use_platform_client():
1188
+ if search_path:
1189
+ providers = await Provider.list()
1190
+ provider = select_provider(search_path, providers)
1191
+ provider_id = str(provider.id)
1192
+
1193
+ response = await UserFeedback.list(
1194
+ provider_id=provider_id,
1195
+ limit=limit,
1196
+ after_cursor=after_cursor,
1197
+ )
1198
+
1199
+ if not response.items:
1200
+ console.print("No feedback found.")
1201
+ return
1202
+
1203
+ with create_table(
1204
+ Column("Rating", style="yellow", ratio=1),
1205
+ Column("Agent", style="cyan", ratio=2),
1206
+ Column("Task ID", style="dim", ratio=1),
1207
+ Column("Comment", ratio=3),
1208
+ Column("Tags", ratio=2),
1209
+ Column("Date", style="dim", ratio=1),
1210
+ ) as table:
1211
+ for item in response.items:
1212
+ rating_icon = "✓" if item.rating == 1 else "✗"
1213
+ agent_name = item.agent_name or str(item.provider_id)[:8]
1214
+ task_id_short = str(item.task_id)[:8]
1215
+ comment = item.comment or ""
1216
+ if len(comment) > 50:
1217
+ comment = comment[:50] + "..."
1218
+ tags = ", ".join(item.comment_tags or []) if item.comment_tags else "-"
1219
+ created_at = item.created_at.strftime("%Y-%m-%d")
1220
+
1221
+ table.add_row(rating_icon, agent_name, task_id_short, comment, tags, created_at)
1222
+
1223
+ console.print(table)
1224
+ console.print(f"Showing {len(response.items)} of {response.total_count} total feedback entries")
1225
+ if response.has_more and response.next_page_token:
1226
+ console.print(f"Use --after {response.next_page_token} to see more")
@@ -152,10 +152,10 @@ async def _server_side_build(
152
152
  verbose: bool = False,
153
153
  ) -> ProviderBuild:
154
154
  build = None
155
- try:
156
- from agentstack_cli.commands.agent import select_provider
157
- from agentstack_cli.configuration import Configuration
155
+ from agentstack_cli.commands.agent import select_provider
156
+ from agentstack_cli.configuration import Configuration
158
157
 
158
+ try:
159
159
  if replace and add:
160
160
  raise ValueError("Cannot specify both replace and add options.")
161
161
 
@@ -181,8 +181,9 @@ async def _server_side_build(
181
181
  print_log(message, ansi_mode=True, out_console=err_console)
182
182
  return await build.get()
183
183
  except (KeyboardInterrupt, CancelledError):
184
- if build:
185
- await build.delete()
184
+ async with Configuration().use_platform_client():
185
+ if build:
186
+ await build.delete()
186
187
  console.error("Build aborted.")
187
188
  raise
188
189
 
@@ -354,7 +354,8 @@ async def _select_default_model(capability: ModelCapability) -> str | None:
354
354
  if capability == ModelCapability.LLM:
355
355
  test_response = await client.chat.completions.create(
356
356
  model=selected_model,
357
- max_completion_tokens=500, # reasoning models need some tokens to think about this
357
+ # reasoning models need some tokens to think about this
358
+ max_completion_tokens=500 if not selected_model.startswith("mistral") else None,
358
359
  messages=[
359
360
  {
360
361
  "role": "system",
@@ -10,7 +10,6 @@ from subprocess import CompletedProcess
10
10
  from textwrap import dedent
11
11
 
12
12
  import anyio
13
- import pydantic
14
13
  import yaml
15
14
  from tenacity import AsyncRetrying, stop_after_attempt
16
15
 
@@ -193,27 +192,3 @@ class BaseDriver(abc.ABC):
193
192
  ["k3s", "kubectl", "rollout", "restart", "deployment"],
194
193
  "Restarting deployments to load imported images",
195
194
  )
196
-
197
- async def version(self) -> str | None:
198
- if (await self.status()) != "running":
199
- return None
200
- HelmStatus = typing.TypedDict("HelmStatus", {"status": str, "app_version": str})
201
- helm_status = pydantic.TypeAdapter(list[HelmStatus]).validate_json(
202
- (
203
- await self.run_in_vm(
204
- [
205
- "/usr/local/bin/helm",
206
- "--kubeconfig=/etc/rancher/k3s/k3s.yaml",
207
- "ls",
208
- "--namespace=default",
209
- "--filter=^agentstack$",
210
- "-o",
211
- "json",
212
- ],
213
- "Getting Agent Stack platform version",
214
- )
215
- ).stdout
216
- )
217
- if helm_status[0]["status"] != "deployed":
218
- return None
219
- return helm_status[0]["app_version"]
@@ -15,6 +15,7 @@ import typer
15
15
  from InquirerPy import inquirer
16
16
 
17
17
  import agentstack_cli.commands.platform
18
+ from agentstack_cli.api import fetch_server_version
18
19
  from agentstack_cli.async_typer import AsyncTyper
19
20
  from agentstack_cli.commands.model import setup as model_setup
20
21
  from agentstack_cli.configuration import Configuration
@@ -46,7 +47,8 @@ async def version(
46
47
  """Print version of the Agent Stack CLI."""
47
48
  with verbosity(verbose=verbose):
48
49
  cli_version = importlib.metadata.version("agentstack-cli")
49
- platform_version = await agentstack_cli.commands.platform.get_driver().version()
50
+ platform_version = await fetch_server_version()
51
+ active_server = configuration.auth_manager.active_server
50
52
 
51
53
  latest_cli_version: str | None = None
52
54
  with console.status("Checking for newer version...", spinner="dots"):
@@ -64,6 +66,7 @@ async def version(
64
66
  console.print(
65
67
  f"agentstack-platform version: [bold]{platform_version.replace('-', '') if platform_version is not None else 'not running'}[/bold]"
66
68
  )
69
+ console.print(f" agentstack server: [bold]{active_server if active_server else 'none'}[/bold]")
67
70
  console.print()
68
71
 
69
72
  if latest_cli_version and packaging.version.parse(latest_cli_version) > packaging.version.parse(cli_version):
@@ -0,0 +1,92 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import typing
5
+ from datetime import datetime
6
+
7
+ import typer
8
+ from agentstack_sdk.platform import User
9
+ from agentstack_sdk.platform.user import UserRole
10
+ from rich.table import Column
11
+
12
+ from agentstack_cli.async_typer import AsyncTyper, console, create_table
13
+ from agentstack_cli.configuration import Configuration
14
+ from agentstack_cli.utils import announce_server_action, confirm_server_action
15
+
16
+ app = AsyncTyper()
17
+ configuration = Configuration()
18
+
19
+ ROLE_DISPLAY = {
20
+ "admin": "[red]admin[/red]",
21
+ "developer": "[cyan]developer[/cyan]",
22
+ "user": "user",
23
+ }
24
+
25
+
26
+ @app.command("list")
27
+ async def list_users(
28
+ email: typing.Annotated[str | None, typer.Option(help="Filter by email (case-insensitive partial match)")] = None,
29
+ limit: typing.Annotated[int, typer.Option(help="Results per page (1-100)")] = 40,
30
+ after: typing.Annotated[str | None, typer.Option(help="Pagination cursor (page_token)")] = None,
31
+ ):
32
+ """List platform users (admin only)."""
33
+ announce_server_action("Listing users on")
34
+
35
+ async with configuration.use_platform_client():
36
+ result = await User.list(email=email, limit=limit, page_token=after)
37
+
38
+ items = result.items
39
+ has_more = result.has_more
40
+ next_page_token = result.next_page_token
41
+
42
+ with create_table(
43
+ Column("ID", style="yellow"),
44
+ Column("Email"),
45
+ Column("Role"),
46
+ Column("Created"),
47
+ Column("Role Updated"),
48
+ no_wrap=True,
49
+ ) as table:
50
+ for user in items:
51
+ role_display = ROLE_DISPLAY.get(user.role, user.role)
52
+
53
+ created_at = _format_date(user.created_at)
54
+ role_updated_at = _format_date(user.role_updated_at) if user.role_updated_at else "-"
55
+
56
+ table.add_row(
57
+ user.id,
58
+ user.email,
59
+ role_display,
60
+ created_at,
61
+ role_updated_at,
62
+ )
63
+
64
+ console.print()
65
+ console.print(table)
66
+
67
+ if has_more and next_page_token:
68
+ console.print(f"\n[dim]Use --after {next_page_token} to see more[/dim]")
69
+
70
+
71
+ @app.command("set-role")
72
+ async def set_role(
73
+ user_id: typing.Annotated[str, typer.Argument(help="User UUID")],
74
+ role: typing.Annotated[UserRole, typer.Argument(help="Target role (admin, developer, user)")],
75
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
76
+ ):
77
+ """Change user role (admin only)."""
78
+ url = announce_server_action(f"Changing user {user_id} to role '{role}' on")
79
+ await confirm_server_action("Proceed with role change on", url=url, yes=yes)
80
+
81
+ async with configuration.use_platform_client():
82
+ result = await User.set_role(user_id, UserRole(role))
83
+
84
+ role_display = ROLE_DISPLAY.get(result.new_role, result.new_role)
85
+
86
+ console.success(f"User role updated to [cyan]{role_display}[/cyan]")
87
+
88
+
89
+ def _format_date(dt: datetime | None) -> str:
90
+ if not dt:
91
+ return "-"
92
+ return dt.strftime("%Y-%m-%d %H:%M")
@@ -142,7 +142,7 @@ async def confirm_server_action(message: str, url: str | None = None, *, yes: bo
142
142
  return
143
143
  url = url or require_active_server()
144
144
  confirmed = await inquirer.confirm( # type: ignore
145
- message=f"{message} [cyan]{url}[/cyan]?", default=False
145
+ message=f"{message} {url}?", default=False
146
146
  ).execute_async()
147
147
  if not confirmed:
148
148
  console.info("Action cancelled.")
@@ -309,10 +309,15 @@ def print_log(line, ansi_mode=False, out_console: Console | None = None):
309
309
  def decode(text: str):
310
310
  return Text.from_ansi(text) if ansi_mode else text
311
311
 
312
- if line["stream"] == "stderr":
313
- (out_console or err_console).print(decode(line["message"]))
314
- elif line["stream"] == "stdout":
315
- (out_console or console).print(decode(line["message"]))
312
+ match line:
313
+ case {"stream": "stderr"}:
314
+ (out_console or err_console).print(decode(line["message"]))
315
+ case {"stream": "stdout"}:
316
+ (out_console or console).print(decode(line["message"]))
317
+ case {"event": "[DONE]"}:
318
+ return
319
+ case _:
320
+ (out_console or console).print(line)
316
321
 
317
322
 
318
323
  def is_github_url(url: str) -> bool: