agentstack-cli 0.4.2rc1__tar.gz → 0.4.2rc3__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.2rc1 → agentstack_cli-0.4.2rc3}/PKG-INFO +1 -1
  2. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/pyproject.toml +1 -1
  3. agentstack_cli-0.4.2rc3/src/agentstack_cli/__init__.py +116 -0
  4. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/api.py +26 -12
  5. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/async_typer.py +2 -1
  6. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/agent.py +95 -69
  7. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/build.py +14 -1
  8. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/mcp.py +12 -3
  9. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/model.py +27 -2
  10. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/platform/wsl_driver.py +7 -2
  11. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/server.py +1 -1
  12. agentstack_cli-0.4.2rc3/src/agentstack_cli/data/helm-chart.tgz +0 -0
  13. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/utils.py +34 -1
  14. agentstack_cli-0.4.2rc1/src/agentstack_cli/__init__.py +0 -77
  15. agentstack_cli-0.4.2rc1/src/agentstack_cli/data/helm-chart.tgz +0 -0
  16. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/README.md +0 -0
  17. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/auth_manager.py +0 -0
  18. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/__init__.py +0 -0
  19. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/platform/__init__.py +0 -0
  20. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/platform/base_driver.py +0 -0
  21. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/platform/istio.py +0 -0
  22. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/platform/lima_driver.py +0 -0
  23. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/self.py +0 -0
  24. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/configuration.py +0 -0
  25. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/console.py +0 -0
  26. {agentstack_cli-0.4.2rc1 → agentstack_cli-0.4.2rc3}/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.2rc1
3
+ Version: 0.4.2rc3
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.2-rc1"
3
+ version = "0.4.2-rc3"
4
4
  description = "Agent Stack CLI"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "IBM Corp." }]
@@ -0,0 +1,116 @@
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, local) │
37
+ │ remove Uninstall an agent │
38
+ │ info Show agent details │
39
+ │ logs Stream agent execution logs │
40
+ │ build Build an agent container image │
41
+ │ env Manage agent environment variables │
42
+ │ server-side-build [EXPERIMENTAL] Build agents remotely │
43
+ ╰────────────────────────────────────────────────────────────────────────────╯
44
+
45
+ ╭─ Platform & Configuration ─────────────────────────────────────────────────╮
46
+ │ model Configure 15+ LLM providers │
47
+ │ platform Start, stop, or delete local platform │
48
+ │ server Connect to remote Agent Stack servers │
49
+ │ self version Show Agent Stack CLI and Platform version │
50
+ │ self upgrade Upgrade Agent Stack CLI and Platform │
51
+ │ self uninstall Uninstall Agent Stack CLI and Platform │
52
+ ╰────────────────────────────────────────────────────────────────────────────╯
53
+
54
+ ╭─ Options ──────────────────────────────────────────────────────────────────╮
55
+ │ --help Show this help message │
56
+ │ --show-completion Show tab completion script │
57
+ │ --install-completion Enable tab completion for commands │
58
+ ╰────────────────────────────────────────────────────────────────────────────╯
59
+ """
60
+
61
+
62
+ app = AsyncTyper(no_args_is_help=True, cls=RootHelpGroup)
63
+ app.add_typer(agentstack_cli.commands.model.app, name="model", no_args_is_help=True, help="Manage model providers.")
64
+ app.add_typer(agentstack_cli.commands.agent.app, name="agent", no_args_is_help=True, help="Manage agents.")
65
+ app.add_typer(
66
+ agentstack_cli.commands.platform.app, name="platform", no_args_is_help=True, help="Manage Agent Stack platform."
67
+ )
68
+ app.add_typer(
69
+ agentstack_cli.commands.mcp.app, name="mcp", no_args_is_help=True, help="Manage MCP servers and toolkits."
70
+ )
71
+ app.add_typer(agentstack_cli.commands.build.app, name="", no_args_is_help=True, help="Build agent images.")
72
+ app.add_typer(
73
+ agentstack_cli.commands.server.app,
74
+ name="server",
75
+ no_args_is_help=True,
76
+ help="Manage Agent Stack servers and authentication.",
77
+ )
78
+ app.add_typer(
79
+ agentstack_cli.commands.self.app,
80
+ name="self",
81
+ no_args_is_help=True,
82
+ help="Manage Agent Stack installation.",
83
+ hidden=True,
84
+ )
85
+
86
+
87
+ agent_alias = deepcopy(agentstack_cli.commands.agent.app)
88
+ for cmd in agent_alias.registered_commands:
89
+ cmd.rich_help_panel = "Agent commands"
90
+
91
+ app.add_typer(agent_alias, name="", no_args_is_help=True)
92
+
93
+
94
+ @app.command("version")
95
+ async def version(verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False):
96
+ """Print version of the Agent Stack CLI."""
97
+ import agentstack_cli.commands.self
98
+
99
+ await agentstack_cli.commands.self.version(verbose=verbose)
100
+
101
+
102
+ @app.command("ui")
103
+ async def ui():
104
+ """Launch the graphical interface."""
105
+ import webbrowser
106
+
107
+ import agentstack_cli.commands.model
108
+
109
+ await agentstack_cli.commands.model.ensure_llm_provider()
110
+ webbrowser.open(
111
+ "http://localhost:8334"
112
+ ) # TODO: This always opens the local UI, how to open the UI of a logged in server instead?
113
+
114
+
115
+ if __name__ == "__main__":
116
+ 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
@@ -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 := 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)
@@ -36,6 +36,8 @@ from agentstack_sdk.a2a.extensions import (
36
36
  EmbeddingFulfillment,
37
37
  EmbeddingServiceExtensionClient,
38
38
  EmbeddingServiceExtensionSpec,
39
+ FormRequestExtensionSpec,
40
+ FormServiceExtensionSpec,
39
41
  LLMFulfillment,
40
42
  LLMServiceExtensionClient,
41
43
  LLMServiceExtensionSpec,
@@ -44,17 +46,18 @@ from agentstack_sdk.a2a.extensions import (
44
46
  TrajectoryExtensionClient,
45
47
  TrajectoryExtensionSpec,
46
48
  )
47
- from agentstack_sdk.a2a.extensions.ui.form import (
49
+ from agentstack_sdk.a2a.extensions.common.form import (
48
50
  CheckboxField,
49
51
  CheckboxFieldValue,
50
52
  DateField,
51
53
  DateFieldValue,
52
- FormExtensionSpec,
53
54
  FormFieldValue,
54
55
  FormRender,
55
56
  FormResponse,
56
57
  MultiSelectField,
57
58
  MultiSelectFieldValue,
59
+ SingleSelectField,
60
+ SingleSelectFieldValue,
58
61
  TextField,
59
62
  TextFieldValue,
60
63
  )
@@ -84,6 +87,7 @@ if sys.platform != "win32":
84
87
  from collections.abc import Callable
85
88
  from pathlib import Path
86
89
  from typing import Any
90
+ from urllib.parse import urlparse
87
91
 
88
92
  import jsonschema
89
93
  import rich.json
@@ -94,6 +98,8 @@ from rich.table import Column
94
98
  from agentstack_cli.api import a2a_client
95
99
  from agentstack_cli.async_typer import AsyncTyper, console, create_table, err_console
96
100
  from agentstack_cli.utils import (
101
+ announce_server_action,
102
+ confirm_server_action,
97
103
  generate_schema_example,
98
104
  parse_env_var,
99
105
  print_log,
@@ -153,39 +159,44 @@ async def add_agent(
153
159
  dockerfile: typing.Annotated[str | None, typer.Option(help="Use custom dockerfile path")] = None,
154
160
  vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack",
155
161
  verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False,
162
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
156
163
  ) -> None:
157
164
  """Install discovered agent or add public docker image or github repository [aliases: install]"""
165
+ url = announce_server_action(f"Installing agent '{location}' for")
166
+ await confirm_server_action("Proceed with installing this agent on", url=url, yes=yes)
158
167
  agent_card = None
159
- # Try extracting manifest locally for local images
160
168
  with verbosity(verbose):
161
- process = await run_command(["docker", "inspect", location], check=False, message="Inspecting docker images.")
162
- from subprocess import CalledProcessError
163
-
164
- errors = []
169
+ if (
170
+ process := await run_command(
171
+ ["docker", "inspect", location], check=False, message="Inspecting docker images"
172
+ )
173
+ ).returncode == 0:
174
+ console.success(f"Found local image [bold]{location}[/bold]")
175
+ manifest = base64.b64decode(
176
+ json.loads(process.stdout)[0]["Config"]["Labels"]["beeai.dev.agent.json"]
177
+ ).decode()
178
+ agent_card = json.loads(manifest)
179
+ elif (
180
+ Path(location).expanduser().exists()
181
+ or location.startswith("git@")
182
+ or location.startswith("github.com/")
183
+ or location.startswith("www.github.com/")
184
+ or location.endswith(".git")
185
+ or ((u := urlparse(location)).scheme.startswith("http") and u.netloc.endswith("github.com"))
186
+ or u.scheme in {"ssh", "git", "git+ssh"}
187
+ ):
188
+ console.info(f"Assuming build context, attempting to build agent from [bold]{location}[/bold]")
189
+ location, agent_card = await build(location, dockerfile, tag=None, vm_name=vm_name, import_image=True)
190
+ else:
191
+ console.info(f"Assuming public docker image, attempting to pull {location}")
165
192
 
166
- try:
167
- if process.returncode:
168
- # If the image was not found locally, try building image
169
- location, agent_card = await build(location, dockerfile, tag=None, vm_name=vm_name, import_image=True)
170
- else:
171
- manifest = base64.b64decode(
172
- json.loads(process.stdout)[0]["Config"]["Labels"]["beeai.dev.agent.json"]
173
- ).decode()
174
- agent_card = json.loads(manifest)
175
- # If all build and inspect succeeded, use the local image, else use the original; maybe it exists remotely
176
- except CalledProcessError as e:
177
- errors.append(e)
178
- console.print("Attempting to use remote image...")
179
- try:
180
- with status("Registering agent to platform"):
181
- async with configuration.use_platform_client():
182
- await Provider.create(
183
- location=location,
184
- agent_card=AgentCard.model_validate(agent_card) if agent_card else None,
185
- )
186
- console.print("Registering agent to platform [[green]DONE[/green]]")
187
- except Exception as e:
188
- raise ExceptionGroup("Error occured", [*errors, e]) from e
193
+ with status("Registering agent to platform"):
194
+ async with configuration.use_platform_client():
195
+ await Provider.create(
196
+ location=location,
197
+ agent_card=AgentCard.model_validate(agent_card) if agent_card else None,
198
+ )
199
+ console.success(f"Agent [bold]{location}[/bold] added to platform")
189
200
  await list_agents()
190
201
 
191
202
 
@@ -207,8 +218,11 @@ async def uninstall_agent(
207
218
  search_path: typing.Annotated[
208
219
  str, typer.Argument(..., help="Short ID, agent name or part of the provider location")
209
220
  ],
221
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
210
222
  ) -> None:
211
223
  """Remove agent"""
224
+ url = announce_server_action(f"Removing agent '{search_path}' from")
225
+ await confirm_server_action("Proceed with removing this agent from", url=url, yes=yes)
212
226
  with console.status("Uninstalling agent (may take a few minutes)...", spinner="dots"):
213
227
  async with configuration.use_platform_client():
214
228
  remove_provider = select_provider(search_path, await Provider.list()).id
@@ -223,6 +237,7 @@ async def stream_logs(
223
237
  ],
224
238
  ):
225
239
  """Stream agent provider logs"""
240
+ announce_server_action(f"Streaming logs for '{search_path}' from")
226
241
  async with configuration.use_platform_client():
227
242
  provider = select_provider(search_path, await Provider.list()).id
228
243
  async for message in Provider.stream_logs(provider):
@@ -245,6 +260,15 @@ async def _ask_form_questions(form_render: FormRender) -> FormResponse:
245
260
  validate=EmptyInputValidator() if field.required else None,
246
261
  ).execute_async()
247
262
  form_values[field.id] = TextFieldValue(value=answer)
263
+ elif isinstance(field, SingleSelectField):
264
+ choices = [Choice(value=opt.id, name=opt.label) for opt in field.options]
265
+ answer = await inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
266
+ message=field.label + ":",
267
+ choices=choices,
268
+ default=field.default_value,
269
+ validate=EmptyInputValidator() if field.required else None,
270
+ ).execute_async()
271
+ form_values[field.id] = SingleSelectFieldValue(value=answer)
248
272
  elif isinstance(field, MultiSelectField):
249
273
  choices = [Choice(value=opt.id, name=opt.label) for opt in field.options]
250
274
  answer = await inquirer.checkbox( # pyright: ignore[reportPrivateImportUsage]
@@ -254,6 +278,7 @@ async def _ask_form_questions(form_render: FormRender) -> FormResponse:
254
278
  validate=EmptyInputValidator() if field.required else None,
255
279
  ).execute_async()
256
280
  form_values[field.id] = MultiSelectFieldValue(value=answer)
281
+
257
282
  elif isinstance(field, DateField):
258
283
  year = await inquirer.text( # pyright: ignore[reportPrivateImportUsage]
259
284
  message=f"{field.label} (year):",
@@ -295,7 +320,7 @@ async def _ask_form_questions(form_render: FormRender) -> FormResponse:
295
320
  ).execute_async()
296
321
  form_values[field.id] = CheckboxFieldValue(value=answer)
297
322
  console.print()
298
- return FormResponse(id=form_render.id, values=form_values)
323
+ return FormResponse(values=form_values)
299
324
 
300
325
 
301
326
  async def _run_agent(
@@ -360,7 +385,11 @@ async def _run_agent(
360
385
  else {}
361
386
  )
362
387
  | (
363
- {FormExtensionSpec.URI: typing.cast(FormResponse, input).model_dump(mode="json")}
388
+ {
389
+ FormServiceExtensionSpec.URI: {
390
+ "form_fulfillments": {"initial_form": typing.cast(FormResponse, input).model_dump(mode="json")}
391
+ }
392
+ }
364
393
  if isinstance(input, FormResponse)
365
394
  else {}
366
395
  )
@@ -446,7 +475,7 @@ async def _run_agent(
446
475
  raise ValueError("Agent requires input but no input handler provided")
447
476
 
448
477
  if form_metadata := (
449
- message.metadata.get(FormExtensionSpec.URI) if message and message.metadata else None
478
+ message.metadata.get(FormRequestExtensionSpec.URI) if message and message.metadata else None
450
479
  ):
451
480
  stream = client.send_message(
452
481
  Message(
@@ -456,7 +485,7 @@ async def _run_agent(
456
485
  task_id=task_id,
457
486
  context_id=context_token.context_id,
458
487
  metadata={
459
- FormExtensionSpec.URI: (
488
+ FormRequestExtensionSpec.URI: (
460
489
  await _ask_form_questions(FormRender.model_validate(form_metadata))
461
490
  ).model_dump(mode="json")
462
491
  },
@@ -737,6 +766,7 @@ async def run_agent(
737
766
  ] = None,
738
767
  ) -> None:
739
768
  """Run an agent."""
769
+ announce_server_action(f"Running agent '{search_path}' on")
740
770
  async with configuration.use_platform_client():
741
771
  providers = await Provider.list()
742
772
  await ensure_llm_provider()
@@ -778,9 +808,9 @@ async def run_agent(
778
808
 
779
809
  initial_form_render = next(
780
810
  (
781
- FormRender.model_validate(ext.params)
811
+ FormRender.model_validate(ext.params["form_demands"]["initial_form"])
782
812
  for ext in agent.capabilities.extensions or ()
783
- if ext.uri == FormExtensionSpec.URI and ext.params
813
+ if ext.uri == FormServiceExtensionSpec.URI and ext.params
784
814
  ),
785
815
  None,
786
816
  )
@@ -827,19 +857,13 @@ async def run_agent(
827
857
  )
828
858
 
829
859
 
830
- def render_enum(value: str, colors: dict[str, str]) -> str:
831
- if color := colors.get(value):
832
- return f"[{color}]{value}[/{color}]"
833
- return value
834
-
835
-
836
860
  @app.command("list")
837
861
  async def list_agents():
838
862
  """List agents."""
863
+ announce_server_action("Listing agents on")
839
864
  async with configuration.use_platform_client():
840
865
  providers = await Provider.list()
841
866
  max_provider_len = max(len(ProviderUtils.short_location(p)) for p in providers) if providers else 0
842
- max_error_len = max(len(ProviderUtils.last_error(p) or "") for p in providers) if providers else 0
843
867
 
844
868
  def _sort_fn(provider: Provider):
845
869
  state = {"missing": "1"}
@@ -852,39 +876,32 @@ async def list_agents():
852
876
  with create_table(
853
877
  Column("Short ID", style="yellow"),
854
878
  Column("Name", style="yellow"),
855
- Column("State", width=len("starting")),
856
- Column("Description", ratio=2),
857
- Column("Interaction"),
879
+ Column("State"),
858
880
  Column("Location", max_width=min(max(max_provider_len, len("Location")), 70)),
859
- Column("Missing Env", max_width=50),
860
- Column("Last Error", max_width=min(max(max_error_len, len("Last Error")), 50)),
881
+ Column("Info", ratio=2),
861
882
  no_wrap=True,
862
883
  ) as table:
863
884
  for provider in sorted(providers, key=_sort_fn):
864
- state = None
865
- missing_env = None
866
- state = provider.state
867
- missing_env = ",".join(var.name for var in provider.missing_configuration)
868
885
  table.add_row(
869
886
  provider.id[:8],
870
887
  provider.agent_card.name,
871
- render_enum(
872
- state or "<unknown>",
873
- {
874
- "running": "green",
875
- "online": "green",
876
- "ready": "blue",
877
- "starting": "blue",
878
- "missing": "grey",
879
- "offline": "grey",
880
- "error": "red",
881
- },
882
- ),
883
- (provider.agent_card.description or "<none>").replace("\n", " "),
884
- (ProviderUtils.detail(provider) or {}).get("interaction_mode") or "<none>",
888
+ {
889
+ "running": "[green]▶ running[/green]",
890
+ "online": "[green]● connected[/green]",
891
+ "ready": "[green]● idle[/green]",
892
+ "starting": "[yellow]✱ starting[/yellow]",
893
+ "missing": "[bright_black]○ not started[/bright_black]",
894
+ "offline": "[bright_black]○ disconnected[/bright_black]",
895
+ "error": "[red]✘ error[/red]",
896
+ }.get(provider.state, provider.state or "<unknown>"),
885
897
  ProviderUtils.short_location(provider) or "<none>",
886
- missing_env or "<none>",
887
- ProviderUtils.last_error(provider) or "<none>",
898
+ (
899
+ f"Error: {error}"
900
+ if provider.state == "error" and (error := ProviderUtils.last_error(provider))
901
+ else f"Missing ENV: {{{', '.join(missing_env)}}}"
902
+ if (missing_env := [var.name for var in provider.missing_configuration])
903
+ else "<none>"
904
+ ),
888
905
  )
889
906
  console.print(table)
890
907
 
@@ -931,7 +948,9 @@ async def agent_detail(
931
948
  ],
932
949
  ):
933
950
  """Show agent details."""
934
- provider = select_provider(search_path, await Provider.list())
951
+ announce_server_action(f"Showing agent details for '{search_path}' on")
952
+ async with configuration.use_platform_client():
953
+ provider = select_provider(search_path, await Provider.list())
935
954
  agent = provider.agent_card
936
955
 
937
956
  basic_info = f"# {agent.name}\n{agent.description}"
@@ -977,8 +996,11 @@ async def add_env(
977
996
  str, typer.Argument(..., help="Short ID, agent name or part of the provider location")
978
997
  ],
979
998
  env: typing.Annotated[list[str], typer.Argument(help="Environment variables to pass to agent")],
999
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
980
1000
  ) -> None:
981
1001
  """Store environment variables"""
1002
+ url = announce_server_action(f"Adding environment variables for '{search_path}' on")
1003
+ await confirm_server_action("Apply these environment variable changes on", url=url, yes=yes)
982
1004
  env_vars = dict(parse_env_var(var) for var in env)
983
1005
  async with configuration.use_platform_client():
984
1006
  provider = select_provider(search_path, await Provider.list())
@@ -993,6 +1015,7 @@ async def list_env(
993
1015
  ],
994
1016
  ):
995
1017
  """List stored environment variables"""
1018
+ announce_server_action(f"Listing environment variables for '{search_path}' on")
996
1019
  async with configuration.use_platform_client():
997
1020
  provider = select_provider(search_path, await Provider.list())
998
1021
  await _list_env(provider)
@@ -1004,7 +1027,10 @@ async def remove_env(
1004
1027
  str, typer.Argument(..., help="Short ID, agent name or part of the provider location")
1005
1028
  ],
1006
1029
  env: typing.Annotated[list[str], typer.Argument(help="Environment variable(s) to remove")],
1030
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
1007
1031
  ):
1032
+ url = announce_server_action(f"Removing environment variables from '{search_path}' on")
1033
+ await confirm_server_action("Remove the selected environment variables on", url=url, yes=yes)
1008
1034
  async with configuration.use_platform_client():
1009
1035
  provider = select_provider(search_path, await Provider.list())
1010
1036
  await provider.update_variables(variables=dict.fromkeys(env))
@@ -24,7 +24,16 @@ from tenacity import AsyncRetrying, retry_if_exception_type, stop_after_delay, w
24
24
 
25
25
  from agentstack_cli.async_typer import AsyncTyper
26
26
  from agentstack_cli.console import console
27
- from agentstack_cli.utils import capture_output, extract_messages, print_log, run_command, status, verbosity
27
+ from agentstack_cli.utils import (
28
+ announce_server_action,
29
+ capture_output,
30
+ confirm_server_action,
31
+ extract_messages,
32
+ print_log,
33
+ run_command,
34
+ status,
35
+ verbosity,
36
+ )
28
37
 
29
38
 
30
39
  async def find_free_port():
@@ -145,6 +154,7 @@ async def server_side_build_experimental(
145
154
  str | None, typer.Option(help="Short ID, agent name or part of the provider location")
146
155
  ] = None,
147
156
  add: typing.Annotated[bool, typer.Option(help="Add agent to the platform after build")] = False,
157
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
148
158
  ):
149
159
  """EXPERIMENTAL: Build agent from github repository in the platform."""
150
160
  from agentstack_cli.commands.agent import select_provider
@@ -157,6 +167,9 @@ async def server_side_build_experimental(
157
167
  if dockerfile:
158
168
  build_configuration = BuildConfiguration(dockerfile_path=Path(dockerfile))
159
169
 
170
+ url = announce_server_action(f"Starting server-side build for '{github_url}' on")
171
+ await confirm_server_action("Proceed with building this agent on", url=url, yes=yes)
172
+
160
173
  async with Configuration().use_platform_client():
161
174
  on_complete = None
162
175
  if replace:
@@ -9,9 +9,7 @@ from rich.table import Column
9
9
 
10
10
  from agentstack_cli.api import api_request
11
11
  from agentstack_cli.async_typer import AsyncTyper, console, create_table
12
- from agentstack_cli.utils import (
13
- status,
14
- )
12
+ from agentstack_cli.utils import announce_server_action, confirm_server_action, status
15
13
 
16
14
  app = AsyncTyper()
17
15
 
@@ -28,9 +26,12 @@ async def add_provider(
28
26
  transport: typing.Annotated[
29
27
  Transport, typer.Argument(help="Transport the MCP server uses")
30
28
  ] = Transport.STREAMABLE_HTTP,
29
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
31
30
  ) -> None:
32
31
  """Install discovered MCP server."""
33
32
 
33
+ url = announce_server_action(f"Registering MCP server '{name}' on")
34
+ await confirm_server_action("Proceed with registering this MCP server on", url=url, yes=yes)
34
35
  with status("Registering server to platform"):
35
36
  await api_request(
36
37
  "POST", "mcp/providers", json={"name": name, "location": location, "transport": transport.value}
@@ -43,6 +44,7 @@ async def add_provider(
43
44
  async def list_providers():
44
45
  """List MCP servers."""
45
46
 
47
+ announce_server_action("Listing MCP servers on")
46
48
  providers = await api_request("GET", "mcp/providers")
47
49
  assert providers
48
50
  with create_table(
@@ -61,8 +63,11 @@ async def list_providers():
61
63
  @app.command("remove | uninstall | rm | delete")
62
64
  async def uninstall_provider(
63
65
  name: typing.Annotated[str, typer.Argument(help="Name of the MCP provider to remove")],
66
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
64
67
  ) -> None:
65
68
  """Remove MCP server."""
69
+ url = announce_server_action(f"Removing MCP server '{name}' from")
70
+ await confirm_server_action("Proceed with removing this MCP server from", url=url, yes=yes)
66
71
  provider = await _get_provider_by_name(name)
67
72
  if provider:
68
73
  await api_request("delete", f"mcp/providers/{provider['id']}")
@@ -79,6 +84,7 @@ app.add_typer(tool_app, name="tool", no_args_is_help=True, help="Inspect tools."
79
84
  async def list_tools() -> None:
80
85
  """List tools."""
81
86
 
87
+ announce_server_action("Listing MCP tools on")
82
88
  tools = await api_request("GET", "mcp/tools")
83
89
  assert tools
84
90
  with create_table(
@@ -99,9 +105,12 @@ app.add_typer(toolkit_app, name="toolkit", no_args_is_help=True, help="Create to
99
105
  @toolkit_app.command("create")
100
106
  async def toolkit(
101
107
  tools: typing.Annotated[list[str], typer.Argument(help="Tools to put in the toolkit")],
108
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
102
109
  ) -> None:
103
110
  """Create a toolkit."""
104
111
 
112
+ url = announce_server_action("Creating MCP toolkit on")
113
+ await confirm_server_action("Proceed with creating this MCP toolkit on", url=url, yes=yes)
105
114
  api_tools = await _get_tools_by_names(tools)
106
115
  assert api_tools
107
116
  toolkit = await api_request("POST", "mcp/toolkits", json={"tools": [tool["id"] for tool in api_tools]})
@@ -25,7 +25,7 @@ from rich.table import Column
25
25
  from agentstack_cli.api import openai_client
26
26
  from agentstack_cli.async_typer import AsyncTyper, console, create_table
27
27
  from agentstack_cli.configuration import Configuration
28
- from agentstack_cli.utils import run_command, verbosity
28
+ from agentstack_cli.utils import announce_server_action, confirm_server_action, run_command, verbosity
29
29
 
30
30
  app = AsyncTyper()
31
31
  configuration = Configuration()
@@ -378,6 +378,7 @@ async def _select_default_model(capability: ModelCapability) -> str | None:
378
378
 
379
379
  @app.command("list")
380
380
  async def list_models():
381
+ announce_server_action("Listing models on")
381
382
  async with configuration.use_platform_client():
382
383
  config = await SystemConfiguration.get()
383
384
  async with openai_client() as client:
@@ -415,6 +416,7 @@ async def setup(
415
416
  verbose: typing.Annotated[bool, typer.Option("-v")] = False,
416
417
  ):
417
418
  """Interactive setup for LLM and embedding provider environment variables"""
419
+ announce_server_action("Configuring model providers for")
418
420
 
419
421
  with verbosity(verbose):
420
422
  async with configuration.use_platform_client():
@@ -439,7 +441,17 @@ async def setup(
439
441
  default_llm_model = await _select_default_model(ModelCapability.LLM)
440
442
 
441
443
  default_embedding_model = None
442
- if ModelCapability.EMBEDDING in llm_provider.capabilities:
444
+ if (
445
+ ModelCapability.EMBEDDING in llm_provider.capabilities
446
+ and llm_provider.type
447
+ != ModelProviderType.RITS # RITS does not support embeddings, but we treat it as OTHER
448
+ and (
449
+ llm_provider.type != ModelProviderType.OTHER # OTHER may not support embeddings, so we ask
450
+ or inquirer.confirm( # type: ignore
451
+ "Do you want to also set up an embedding model from the same provider?", default=True
452
+ )
453
+ )
454
+ ):
443
455
  default_embedding_model = await _select_default_model(ModelCapability.EMBEDDING)
444
456
  elif await inquirer.confirm( # type: ignore
445
457
  message="Do you want to configure an embedding provider? (recommended)", default=True
@@ -447,6 +459,8 @@ async def setup(
447
459
  console.print("[bold]Setting up embedding provider...[/bold]")
448
460
  await _add_provider(capability=ModelCapability.EMBEDDING, use_true_localhost=use_true_localhost)
449
461
  default_embedding_model = await _select_default_model(ModelCapability.EMBEDDING)
462
+ else:
463
+ console.hint("You can add an embedding provider later with: [green]agentstack model add[/green]")
450
464
 
451
465
  with console.status("Saving configuration...", spinner="dots"):
452
466
  await SystemConfiguration.update(
@@ -468,7 +482,10 @@ async def select_default_model(
468
482
  ModelCapability | None, typer.Argument(help="Which default model to change (llm/embedding)")
469
483
  ] = None,
470
484
  model_id: typing.Annotated[str | None, typer.Argument(help="Model ID to be used as default")] = None,
485
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
471
486
  ):
487
+ url = announce_server_action("Updating default model for")
488
+ await confirm_server_action("Proceed with updating default model on", url=url, yes=yes)
472
489
  if not capability:
473
490
  capability = await inquirer.select( # type: ignore
474
491
  message="Which default model would you like to change?",
@@ -479,6 +496,8 @@ async def select_default_model(
479
496
  ).execute_async()
480
497
 
481
498
  assert capability
499
+ capability_name = str(getattr(capability, "value", capability)).lower()
500
+ await confirm_server_action(f"Proceed with updating the default {capability_name} model on", url=url, yes=yes)
482
501
  async with configuration.use_platform_client():
483
502
  model = model_id if model_id else await _select_default_model(capability)
484
503
  conf = await SystemConfiguration.get()
@@ -504,6 +523,7 @@ def _list_providers(providers: list[ModelProvider]):
504
523
 
505
524
  @model_provider_app.command("list")
506
525
  async def list_model_providers():
526
+ announce_server_action("Listing model providers on")
507
527
  async with configuration.use_platform_client():
508
528
  providers = await ModelProvider.list()
509
529
  _list_providers(providers)
@@ -516,6 +536,7 @@ async def add_provider(
516
536
  ModelCapability | None, typer.Argument(help="Which default model to change (llm/embedding)")
517
537
  ] = None,
518
538
  ):
539
+ announce_server_action("Adding provider for")
519
540
  if not capability:
520
541
  capability = await inquirer.select( # type: ignore
521
542
  message="Which default provider would you like to add?",
@@ -561,7 +582,11 @@ async def remove_provider(
561
582
  search_path: typing.Annotated[
562
583
  str | None, typer.Argument(..., help="Provider type or part of the provider base url")
563
584
  ] = None,
585
+ yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
564
586
  ):
587
+ descriptor = search_path or "selected provider"
588
+ url = announce_server_action(f"Removing model provider '{descriptor}' from")
589
+ await confirm_server_action("Proceed with removing the selected model provider from", url=url, yes=yes)
565
590
  async with configuration.use_platform_client():
566
591
  conf = await SystemConfiguration.get()
567
592
 
@@ -57,8 +57,13 @@ class WSLDriver(BaseDriver):
57
57
  @typing.override
58
58
  async def create_vm(self):
59
59
  if (await run_command(["wsl.exe", "--status"], "Checking for WSL2", check=False)).returncode != 0:
60
- await run_command(["wsl.exe", "--install", "--no-launch", "--web-download"], "Installing WSL2")
61
- await run_command(["wsl.exe", "--upgrade"], "Upgrading WSL2", check=False)
60
+ console.error(
61
+ "WSL is not installed. Please follow the Agent Stack installation instructions: https://agentstack.beeai.dev/introduction/quickstart#windows"
62
+ )
63
+ console.hint(
64
+ "Run [green]wsl.exe --install[/green] as administrator. If you just did this, restart your PC and run the same command again. Full installation may require up to two restarts. WSL is properly set up once you reach a working Linux terminal. You can verify this by running [green]wsl.exe[/green] without arguments."
65
+ )
66
+ sys.exit(1)
62
67
 
63
68
  config_file = (
64
69
  pathlib.Path.home()
@@ -68,7 +68,7 @@ async def _wait_for_auth_code(port: int = 9001) -> str:
68
68
  return code
69
69
 
70
70
 
71
- @app.command("login | change | select | default")
71
+ @app.command("login | change | select | default | switch")
72
72
  async def server_login(server: typing.Annotated[str | None, typer.Argument()] = None):
73
73
  """Login to a server or switch between logged in servers."""
74
74
  server = server or (
@@ -21,6 +21,7 @@ import typer
21
21
  import yaml
22
22
  from anyio import create_task_group
23
23
  from anyio.abc import ByteReceiveStream, TaskGroup
24
+ from InquirerPy import inquirer
24
25
  from jsf import JSF
25
26
  from prompt_toolkit import PromptSession
26
27
  from prompt_toolkit.shortcuts import CompleteStyle
@@ -28,6 +29,7 @@ from pydantic import BaseModel
28
29
  from rich.console import Capture
29
30
  from rich.text import Text
30
31
 
32
+ from agentstack_cli.configuration import Configuration
31
33
  from agentstack_cli.console import console, err_console
32
34
 
33
35
  if TYPE_CHECKING:
@@ -115,6 +117,37 @@ def remove_nullable(schema: dict[str, Any]) -> dict[str, Any]:
115
117
  prompt_session = None
116
118
 
117
119
 
120
+ def require_active_server() -> str:
121
+ """Return the active server URL or exit if none is selected."""
122
+ if url := Configuration().auth_manager.active_server:
123
+ return url
124
+ console.error("No server selected.")
125
+ console.hint(
126
+ "Run [green]agentstack platform start[/green] to start a local server, or [green]agentstack server login[/green] to connect to a remote one."
127
+ )
128
+ sys.exit(1)
129
+
130
+
131
+ def announce_server_action(message: str, url: str | None = None) -> str:
132
+ """Log an info message that includes the active server URL and return it."""
133
+ url = url or require_active_server()
134
+ console.info(f"{message} [cyan]{url}[/cyan]")
135
+ return url
136
+
137
+
138
+ async def confirm_server_action(message: str, url: str | None = None, *, yes: bool = False) -> None:
139
+ """Ask for confirmation before continuing with an action on the active server."""
140
+ if yes:
141
+ return
142
+ url = url or require_active_server()
143
+ confirmed = await inquirer.confirm( # type: ignore
144
+ message=f"{message} [cyan]{url}[/cyan]?", default=False
145
+ ).execute_async()
146
+ if not confirmed:
147
+ console.info("Action cancelled.")
148
+ raise typer.Exit(1)
149
+
150
+
118
151
  def prompt_user(
119
152
  prompt: str | None = None,
120
153
  completer: "Completer | None" = None,
@@ -162,7 +195,7 @@ async def capture_output(process: anyio.abc.Process, stream_contents: list | Non
162
195
  async def receive_logs(stream: ByteReceiveStream, index=0):
163
196
  buffer = BytesIO()
164
197
  async for chunk in stream:
165
- err_console.print(Text.from_ansi(chunk.decode()), style="dim")
198
+ err_console.print(Text.from_ansi(chunk.decode(errors="replace")), style="dim")
166
199
  buffer.write(chunk)
167
200
  if stream_contents:
168
201
  stream_contents[index] = buffer.getvalue()
@@ -1,77 +0,0 @@
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()