agentstack-cli 0.4.2__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.2 → agentstack_cli-0.4.2rc3}/PKG-INFO +1 -1
  2. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/pyproject.toml +1 -1
  3. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/__init__.py +4 -7
  4. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/api.py +3 -3
  5. agentstack_cli-0.4.2rc3/src/agentstack_cli/auth_manager.py +126 -0
  6. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/agent.py +40 -58
  7. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/build.py +47 -60
  8. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/model.py +1 -1
  9. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/platform/__init__.py +5 -5
  10. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/platform/base_driver.py +0 -2
  11. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/self.py +4 -8
  12. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/server.py +4 -39
  13. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/configuration.py +1 -1
  14. agentstack_cli-0.4.2rc3/src/agentstack_cli/data/helm-chart.tgz +0 -0
  15. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/utils.py +4 -27
  16. agentstack_cli-0.4.2/src/agentstack_cli/auth_manager.py +0 -241
  17. agentstack_cli-0.4.2/src/agentstack_cli/data/helm-chart.tgz +0 -0
  18. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/README.md +0 -0
  19. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/async_typer.py +0 -0
  20. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/__init__.py +0 -0
  21. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/mcp.py +0 -0
  22. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/platform/istio.py +0 -0
  23. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/platform/lima_driver.py +0 -0
  24. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/commands/platform/wsl_driver.py +0 -0
  25. {agentstack_cli-0.4.2 → agentstack_cli-0.4.2rc3}/src/agentstack_cli/console.py +0 -0
  26. {agentstack_cli-0.4.2 → 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.2
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"
3
+ version = "0.4.2-rc3"
4
4
  description = "Agent Stack CLI"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "IBM Corp." }]
@@ -33,14 +33,13 @@ Usage: agentstack [OPTIONS] COMMAND [ARGS]...
33
33
  ╰────────────────────────────────────────────────────────────────────────────╯
34
34
 
35
35
  ╭─ Agent Management ─────────────────────────────────────────────────────────╮
36
- │ add Install an agent (Docker, GitHub)
36
+ │ add Install an agent (Docker, GitHub, local)
37
37
  │ remove Uninstall an agent │
38
- │ update Update an agent │
39
38
  │ info Show agent details │
40
39
  │ logs Stream agent execution logs │
40
+ │ build Build an agent container image │
41
41
  │ env Manage agent environment variables │
42
- │ build Build an agent remotely
43
- │ client-side-build Build an agent container image locally │
42
+ server-side-build [EXPERIMENTAL] Build agents remotely
44
43
  ╰────────────────────────────────────────────────────────────────────────────╯
45
44
 
46
45
  ╭─ Platform & Configuration ─────────────────────────────────────────────────╮
@@ -93,9 +92,7 @@ app.add_typer(agent_alias, name="", no_args_is_help=True)
93
92
 
94
93
 
95
94
  @app.command("version")
96
- async def version(
97
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
98
- ):
95
+ async def version(verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False):
99
96
  """Print version of the Agent Stack CLI."""
100
97
  import agentstack_cli.commands.self
101
98
 
@@ -44,7 +44,7 @@ async def api_request(
44
44
  timeout=60,
45
45
  headers=(
46
46
  {"Authorization": f"Bearer {token}"}
47
- if use_auth and (token := await config.auth_manager.load_auth_token())
47
+ if use_auth and (token := config.auth_manager.load_auth_token())
48
48
  else {}
49
49
  ),
50
50
  )
@@ -83,7 +83,7 @@ async def api_stream(
83
83
  timeout=timedelta(hours=1).total_seconds(),
84
84
  headers=(
85
85
  {"Authorization": f"Bearer {token}"}
86
- if use_auth and (token := await config.auth_manager.load_auth_token())
86
+ if use_auth and (token := config.auth_manager.load_auth_token())
87
87
  else {}
88
88
  ),
89
89
  ) as response,
@@ -108,7 +108,7 @@ async def a2a_client(agent_card: AgentCard, use_auth: bool = True) -> AsyncItera
108
108
  async with httpx.AsyncClient(
109
109
  headers=(
110
110
  {"Authorization": f"Bearer {token}"}
111
- if use_auth and (token := await config.auth_manager.load_auth_token())
111
+ if use_auth and (token := config.auth_manager.load_auth_token())
112
112
  else {}
113
113
  ),
114
114
  follow_redirects=True,
@@ -0,0 +1,126 @@
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
+ client_id: str = "df82a687-d647-4247-838b-7080d7d83f6c" # Backwards compatibility default
22
+ client_secret: str | None = None
23
+ token: AuthToken | None = None
24
+
25
+
26
+ class Server(BaseModel):
27
+ authorization_servers: dict[str, AuthServer] = Field(default_factory=dict)
28
+
29
+
30
+ class Auth(BaseModel):
31
+ version: typing.Literal[1] = 1
32
+ servers: defaultdict[str, typing.Annotated[Server, Field(default_factory=Server)]] = Field(
33
+ default_factory=lambda: defaultdict(Server)
34
+ )
35
+ active_server: str | None = None
36
+ active_auth_server: str | None = None
37
+
38
+
39
+ @typing.final
40
+ class AuthManager:
41
+ def __init__(self, config_path: pathlib.Path):
42
+ self._auth_path = config_path
43
+ self._auth = self._load()
44
+
45
+ def _load(self) -> Auth:
46
+ if not self._auth_path.exists():
47
+ return Auth()
48
+ return Auth.model_validate_json(self._auth_path.read_bytes())
49
+
50
+ def _save(self) -> None:
51
+ self._auth_path.write_text(self._auth.model_dump_json(indent=2))
52
+
53
+ def save_auth_token(
54
+ self,
55
+ server: str,
56
+ auth_server: str | None = None,
57
+ client_id: str | None = None,
58
+ client_secret: str | None = None,
59
+ token: dict[str, Any] | None = None,
60
+ ) -> None:
61
+ if auth_server is not None and client_id is not None and token is not None:
62
+ self._auth.servers[server].authorization_servers[auth_server] = AuthServer(
63
+ client_id=client_id, client_secret=client_secret, token=AuthToken(**token)
64
+ )
65
+ else:
66
+ self._auth.servers[server] # touch
67
+ self._save()
68
+
69
+ def load_auth_token(self) -> str | None:
70
+ active_res = self._auth.active_server
71
+ active_auth_server = self._auth.active_auth_server
72
+ if not active_res or not active_auth_server:
73
+ return None
74
+ server = self._auth.servers.get(active_res)
75
+ if not server:
76
+ return None
77
+
78
+ auth_server = server.authorization_servers.get(active_auth_server)
79
+ if not auth_server or not auth_server.token:
80
+ return None
81
+
82
+ return auth_server.token.access_token
83
+
84
+ def clear_auth_token(self, all: bool = False) -> None:
85
+ if all:
86
+ self._auth.servers = defaultdict(Server)
87
+ else:
88
+ if self._auth.active_server and self._auth.active_auth_server:
89
+ del self._auth.servers[self._auth.active_server].authorization_servers[self._auth.active_auth_server]
90
+ if self._auth.active_server and not self._auth.servers[self._auth.active_server].authorization_servers:
91
+ del self._auth.servers[self._auth.active_server]
92
+ self._auth.active_server = None
93
+ self._auth.active_auth_server = None
94
+ self._save()
95
+
96
+ def get_server(self, server: str) -> Server | None:
97
+ return self._auth.servers.get(server)
98
+
99
+ @property
100
+ def servers(self) -> list[str]:
101
+ return list(self._auth.servers.keys())
102
+
103
+ @property
104
+ def active_server(self) -> str | None:
105
+ return self._auth.active_server
106
+
107
+ @active_server.setter
108
+ def active_server(self, server: str | None) -> None:
109
+ if server is not None and server not in self._auth.servers:
110
+ raise ValueError(f"Server {server} not found")
111
+ self._auth.active_server = server
112
+ self._save()
113
+
114
+ @property
115
+ def active_auth_server(self) -> str | None:
116
+ return self._auth.active_auth_server
117
+
118
+ @active_auth_server.setter
119
+ def active_auth_server(self, auth_server: str | None) -> None:
120
+ if auth_server is not None and (
121
+ self._auth.active_server not in self._auth.servers
122
+ or auth_server not in self._auth.servers[self._auth.active_server].authorization_servers
123
+ ):
124
+ raise ValueError(f"Auth server {auth_server} not found in active server")
125
+ self._auth.active_auth_server = auth_server
126
+ self._save()
@@ -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 ModelProvider, Provider
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
@@ -73,7 +73,7 @@ from rich.console import ConsoleRenderable, Group, NewLine
73
73
  from rich.panel import Panel
74
74
  from rich.text import Text
75
75
 
76
- from agentstack_cli.commands.build import _server_side_build
76
+ from agentstack_cli.commands.build import build
77
77
  from agentstack_cli.commands.model import ensure_llm_provider
78
78
  from agentstack_cli.configuration import Configuration
79
79
 
@@ -87,6 +87,7 @@ if sys.platform != "win32":
87
87
  from collections.abc import Callable
88
88
  from pathlib import Path
89
89
  from typing import Any
90
+ from urllib.parse import urlparse
90
91
 
91
92
  import jsonschema
92
93
  import rich.json
@@ -100,11 +101,11 @@ from agentstack_cli.utils import (
100
101
  announce_server_action,
101
102
  confirm_server_action,
102
103
  generate_schema_example,
103
- is_github_url,
104
104
  parse_env_var,
105
105
  print_log,
106
106
  prompt_user,
107
107
  remove_nullable,
108
+ run_command,
108
109
  status,
109
110
  verbosity,
110
111
  )
@@ -152,67 +153,49 @@ configuration = Configuration()
152
153
 
153
154
  @app.command("add")
154
155
  async def add_agent(
155
- location: typing.Annotated[str, typer.Argument(help="Agent location (public docker image or github url)")],
156
+ location: typing.Annotated[
157
+ str, typer.Argument(help="Agent location (public docker image, local path or github url)")
158
+ ],
156
159
  dockerfile: typing.Annotated[str | None, typer.Option(help="Use custom dockerfile path")] = None,
157
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
160
+ vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack",
161
+ verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False,
158
162
  yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
159
163
  ) -> None:
160
- """Add a docker image or GitHub repository [aliases: install]"""
164
+ """Install discovered agent or add public docker image or github repository [aliases: install]"""
161
165
  url = announce_server_action(f"Installing agent '{location}' for")
162
166
  await confirm_server_action("Proceed with installing this agent on", url=url, yes=yes)
167
+ agent_card = None
163
168
  with verbosity(verbose):
164
- if is_github_url(location):
165
- console.info(f"Assuming GitHub repository, attempting to build agent from [bold]{location}[/bold]")
166
- with status("Building agent"):
167
- build = await _server_side_build(location, dockerfile, add=True, verbose=verbose)
168
- if build.status != BuildState.COMPLETED:
169
- error = build.error_message or "see logs above for details"
170
- raise RuntimeError(f"Agent build failed: {error}")
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)
171
190
  else:
172
- if dockerfile:
173
- raise ValueError("Dockerfile can be specified only if location is a GitHub url")
174
- console.info(f"Assuming public docker image or network address, attempting to add {location}")
175
- with status("Registering agent to platform"):
176
- async with configuration.use_platform_client():
177
- await Provider.create(location=location)
178
- console.success(f"Agent [bold]{location}[/bold] added to platform")
179
- await list_agents()
191
+ console.info(f"Assuming public docker image, attempting to pull {location}")
180
192
 
181
-
182
- @app.command("update")
183
- async def update_agent(
184
- 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)")],
188
- dockerfile: typing.Annotated[str | None, typer.Option(help="Use custom dockerfile path")] = None,
189
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
190
- yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
191
- ) -> None:
192
- """Upgrade agent to a newer docker image or build from GitHub repository"""
193
- with verbosity(verbose):
194
- async with configuration.use_platform_client():
195
- provider = select_provider(search_path, providers=await Provider.list())
196
-
197
- url = announce_server_action(f"Upgrading agent from '{provider.source}' to {location}")
198
- await confirm_server_action("Proceed with upgrading agent on", url=url, yes=yes)
199
-
200
- if is_github_url(location):
201
- console.info(f"Assuming GitHub repository, attempting to build agent from [bold]{location}[/bold]")
202
- with status("Building agent"):
203
- build = await _server_side_build(
204
- github_url=location, dockerfile=dockerfile, replace=provider.id, verbose=verbose
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,
205
198
  )
206
- if build.status != BuildState.COMPLETED:
207
- error = build.error_message or "see logs above for details"
208
- raise RuntimeError(f"Agent build failed: {error}")
209
- else:
210
- if dockerfile:
211
- raise ValueError("Dockerfile can be specified only if location is a GitHub url")
212
- console.info(f"Assuming public docker image or network address, attempting to add {location}")
213
- with status("Upgrading agent in the platform"):
214
- async with configuration.use_platform_client():
215
- await provider.patch(location=location)
216
199
  console.success(f"Agent [bold]{location}[/bold] added to platform")
217
200
  await list_agents()
218
201
 
@@ -535,8 +518,7 @@ async def _run_agent(
535
518
  error = ""
536
519
  if message and message.parts and isinstance(message.parts[0].root, TextPart):
537
520
  error = message.parts[0].root.text
538
- console.print(f"\n:boom: [red][bold]Task {status.value}[/bold][/red]")
539
- console.print(Markdown(error))
521
+ console.print(f"[red]Task {status}[/red]: {error}")
540
522
  return
541
523
  case Task(id=task_id), TaskStatusUpdateEvent(
542
524
  status=TaskStatus(state=TaskState.auth_required, message=message)
@@ -796,7 +778,7 @@ async def run_agent(
796
778
  metadata={"provider_id": provider.id, "agent_name": provider.agent_card.name},
797
779
  )
798
780
  context_token = await context.generate_token(
799
- grant_global_permissions=Permissions(llm={"*"}, embeddings={"*"}, a2a_proxy={"*"}, providers={"read"}),
781
+ grant_global_permissions=Permissions(llm={"*"}, embeddings={"*"}, a2a_proxy={"*"}),
800
782
  grant_context_permissions=ContextPermissions(files={"*"}, vector_stores={"*"}, context_data={"*"}),
801
783
  )
802
784
 
@@ -8,7 +8,6 @@ import re
8
8
  import sys
9
9
  import typing
10
10
  import uuid
11
- from asyncio import CancelledError
12
11
  from contextlib import suppress
13
12
  from datetime import timedelta
14
13
  from pathlib import Path
@@ -18,13 +17,13 @@ import anyio.abc
18
17
  import typer
19
18
  from a2a.utils import AGENT_CARD_WELL_KNOWN_PATH
20
19
  from agentstack_sdk.platform import AddProvider, BuildConfiguration, Provider, UpdateProvider
21
- from agentstack_sdk.platform.provider_build import ProviderBuild
20
+ from agentstack_sdk.platform.provider_build import BuildState, ProviderBuild
22
21
  from anyio import open_process
23
22
  from httpx import AsyncClient, HTTPError
24
23
  from tenacity import AsyncRetrying, retry_if_exception_type, stop_after_delay, wait_fixed
25
24
 
26
25
  from agentstack_cli.async_typer import AsyncTyper
27
- from agentstack_cli.console import console, err_console
26
+ from agentstack_cli.console import console
28
27
  from agentstack_cli.utils import (
29
28
  announce_server_action,
30
29
  capture_output,
@@ -48,8 +47,8 @@ async def find_free_port():
48
47
  app = AsyncTyper()
49
48
 
50
49
 
51
- @app.command("client-side-build")
52
- async def client_side_build(
50
+ @app.command("build")
51
+ async def build(
53
52
  context: typing.Annotated[str, typer.Argument(help="Docker context for the agent")] = ".",
54
53
  dockerfile: typing.Annotated[str | None, typer.Option(help="Use custom dockerfile path")] = None,
55
54
  tag: typing.Annotated[str | None, typer.Option(help="Docker tag for the agent")] = None,
@@ -59,9 +58,8 @@ async def client_side_build(
59
58
  bool, typer.Option("--import/--no-import", is_flag=True, help="Import the image into Agent Stack platform")
60
59
  ] = True,
61
60
  vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack",
62
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
61
+ verbose: typing.Annotated[bool, typer.Option("-v")] = False,
63
62
  ):
64
- """Build agent locally using Docker."""
65
63
  with verbosity(verbose):
66
64
  await run_command(["which", "docker"], "Checking docker")
67
65
  image_id = "agentstack-agent-build-tmp:latest"
@@ -144,67 +142,56 @@ async def client_side_build(
144
142
  return tag, agent_card
145
143
 
146
144
 
147
- async def _server_side_build(
148
- github_url: str,
149
- dockerfile: str | None = None,
150
- replace: str | None = None,
151
- add: bool = False,
152
- verbose: bool = False,
153
- ) -> ProviderBuild:
154
- build = None
155
- try:
156
- from agentstack_cli.commands.agent import select_provider
157
- from agentstack_cli.configuration import Configuration
158
-
159
- if replace and add:
160
- raise ValueError("Cannot specify both replace and add options.")
161
-
162
- build_configuration = None
163
- if dockerfile:
164
- build_configuration = BuildConfiguration(dockerfile_path=Path(dockerfile))
165
-
166
- async with Configuration().use_platform_client():
167
- on_complete = None
168
- if replace:
169
- provider = select_provider(replace, await Provider.list())
170
- on_complete = UpdateProvider(provider_id=uuid.UUID(provider.id))
171
- elif add:
172
- on_complete = AddProvider()
173
-
174
- build = await ProviderBuild.create(
175
- location=github_url,
176
- on_complete=on_complete,
177
- build_configuration=build_configuration,
178
- )
179
- with verbosity(verbose):
180
- async for message in build.stream_logs():
181
- print_log(message, ansi_mode=True, out_console=err_console)
182
- return await build.get()
183
- except (KeyboardInterrupt, CancelledError):
184
- if build:
185
- await build.delete()
186
- console.error("Build aborted.")
187
- raise
188
-
189
-
190
- @app.command("build")
191
- async def server_side_build(
145
+ @app.command("server-side-build")
146
+ async def server_side_build_experimental(
192
147
  github_url: typing.Annotated[
193
148
  str, typer.Argument(..., help="Github repository URL (public or private if supported by the platform instance)")
194
149
  ],
195
150
  dockerfile: typing.Annotated[
196
151
  str | None, typer.Option(help="Use custom dockerfile path, relative to github url sub-path")
197
152
  ] = None,
198
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
153
+ replace: typing.Annotated[
154
+ str | None, typer.Option(help="Short ID, agent name or part of the provider location")
155
+ ] = None,
156
+ add: typing.Annotated[bool, typer.Option(help="Add agent to the platform after build")] = False,
199
157
  yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
200
158
  ):
201
- """Build agent from a GitHub repository in the platform."""
159
+ """EXPERIMENTAL: Build agent from github repository in the platform."""
160
+ from agentstack_cli.commands.agent import select_provider
161
+ from agentstack_cli.configuration import Configuration
202
162
 
203
- url = announce_server_action(f"Starting build for '{github_url}' on")
204
- await confirm_server_action("Proceed with building this agent on", url=url, yes=yes)
163
+ if replace and add:
164
+ raise ValueError("Cannot specify both replace and add options.")
165
+
166
+ build_configuration = None
167
+ if dockerfile:
168
+ build_configuration = BuildConfiguration(dockerfile_path=Path(dockerfile))
205
169
 
206
- build = await _server_side_build(github_url=github_url, dockerfile=dockerfile, verbose=verbose)
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)
207
172
 
208
- console.success(
209
- f"Agent built successfully, add it to the platform using: [green]agentstack add {build.destination}[/green]"
210
- )
173
+ async with Configuration().use_platform_client():
174
+ on_complete = None
175
+ if replace:
176
+ provider = select_provider(replace, await Provider.list())
177
+ on_complete = UpdateProvider(provider_id=uuid.UUID(provider.id))
178
+ elif add:
179
+ on_complete = AddProvider()
180
+
181
+ build = await ProviderBuild.create(
182
+ location=github_url,
183
+ on_complete=on_complete,
184
+ build_configuration=build_configuration,
185
+ )
186
+ async for message in build.stream_logs():
187
+ print_log(message, ansi_mode=True)
188
+ build = await build.get()
189
+ if build.status == BuildState.COMPLETED:
190
+ if add:
191
+ message = "Agent added successfully. List agents using [green]agentstack list[/green]"
192
+ else:
193
+ message = f"Agent built successfully, add it to the platform using: [green]agentstack add {build.destination}[/green]"
194
+ console.success(message)
195
+ else:
196
+ error = build.error_message or "see logs above for details"
197
+ console.error(f"Agent build failed: {error}")
@@ -413,7 +413,7 @@ async def _reset_configuration(existing_providers: list[ModelProvider] | None =
413
413
  @app.command("setup")
414
414
  async def setup(
415
415
  use_true_localhost: typing.Annotated[bool, typer.Option(hidden=True)] = False,
416
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
416
+ verbose: typing.Annotated[bool, typer.Option("-v")] = False,
417
417
  ):
418
418
  """Interactive setup for LLM and embedding provider environment variables"""
419
419
  announce_server_action("Configuring model providers for")
@@ -72,7 +72,7 @@ async def start(
72
72
  pathlib.Path | None, typer.Option("-f", help="Set Helm chart values using yaml values file")
73
73
  ] = None,
74
74
  vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack",
75
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
75
+ verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False,
76
76
  ):
77
77
  """Start Agent Stack platform."""
78
78
  import agentstack_cli.commands.server
@@ -127,7 +127,7 @@ async def start(
127
127
  @app.command("stop")
128
128
  async def stop(
129
129
  vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack",
130
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
130
+ verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False,
131
131
  ):
132
132
  """Stop Agent Stack platform."""
133
133
  with verbosity(verbose):
@@ -142,7 +142,7 @@ async def stop(
142
142
  @app.command("delete")
143
143
  async def delete(
144
144
  vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack",
145
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
145
+ verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False,
146
146
  ):
147
147
  """Delete Agent Stack platform."""
148
148
  with verbosity(verbose):
@@ -155,7 +155,7 @@ async def delete(
155
155
  async def import_image_cmd(
156
156
  tag: typing.Annotated[str, typer.Argument(help="Docker image tag to import")],
157
157
  vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack",
158
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
158
+ verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False,
159
159
  ):
160
160
  """Import a local docker image into the Agent Stack platform."""
161
161
  with verbosity(verbose):
@@ -170,7 +170,7 @@ async def import_image_cmd(
170
170
  async def exec_cmd(
171
171
  command: typing.Annotated[list[str] | None, typer.Argument()] = None,
172
172
  vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack",
173
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
173
+ verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False,
174
174
  ):
175
175
  """For debugging -- execute a command inside the Agent Stack platform VM."""
176
176
  with verbosity(verbose, show_success_status=False):
@@ -117,8 +117,6 @@ class BaseDriver(abc.ABC):
117
117
  "generateConversationTitle": False, # TODO: enable when UI implementation is ready
118
118
  "uiLocalSetup": True,
119
119
  },
120
- "providerBuilds": {"enabled": True},
121
- "localDockerRegistry": {"enabled": True},
122
120
  "auth": {"enabled": False},
123
121
  }
124
122
  if values_file:
@@ -40,9 +40,7 @@ def _path() -> str:
40
40
 
41
41
 
42
42
  @app.command("version")
43
- async def version(
44
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
45
- ):
43
+ async def version(verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False):
46
44
  """Print version of the Agent Stack CLI."""
47
45
  with verbosity(verbose=verbose):
48
46
  cli_version = importlib.metadata.version("agentstack-cli")
@@ -80,7 +78,7 @@ async def version(
80
78
 
81
79
  @app.command("install")
82
80
  async def install(
83
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
81
+ verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False,
84
82
  ):
85
83
  """Install Agent Stack platform pre-requisites."""
86
84
  with verbosity(verbose=verbose):
@@ -173,9 +171,7 @@ async def install(
173
171
 
174
172
 
175
173
  @app.command("upgrade")
176
- async def upgrade(
177
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
178
- ):
174
+ async def upgrade(verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False):
179
175
  """Upgrade Agent Stack CLI and Platform to the latest version."""
180
176
  if not shutil.which("uv", path=_path()):
181
177
  console.error("Can't self-upgrade because 'uv' was not found.")
@@ -193,7 +189,7 @@ async def upgrade(
193
189
 
194
190
  @app.command("uninstall")
195
191
  async def uninstall(
196
- verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
192
+ verbose: typing.Annotated[bool, typer.Option("-v", help="Show verbose output")] = False,
197
193
  ):
198
194
  """Uninstall Agent Stack CLI and Platform."""
199
195
  if not shutil.which("uv", path=_path()):
@@ -5,7 +5,6 @@ import asyncio
5
5
  import logging
6
6
  import sys
7
7
  import typing
8
- import uuid
9
8
  import webbrowser
10
9
  from urllib.parse import urlencode
11
10
 
@@ -69,10 +68,6 @@ async def _wait_for_auth_code(port: int = 9001) -> str:
69
68
  return code
70
69
 
71
70
 
72
- def get_unique_app_name() -> str:
73
- return f"Agent Stack CLI {uuid.uuid4()}"
74
-
75
-
76
71
  @app.command("login | change | select | default | switch")
77
72
  async def server_login(server: typing.Annotated[str | None, typer.Argument()] = None):
78
73
  """Login to a server or switch between logged in servers."""
@@ -142,7 +137,6 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
142
137
 
143
138
  client_id = config.client_id
144
139
  client_secret = config.client_secret
145
- registration_token = None
146
140
 
147
141
  if auth_servers:
148
142
  if len(auth_servers) == 1:
@@ -167,35 +161,17 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
167
161
  registration_endpoint = oidc["registration_endpoint"]
168
162
  if not client_id and registration_endpoint:
169
163
  async with httpx.AsyncClient() as client:
170
- resp = None
171
164
  try:
172
- app_name = get_unique_app_name()
173
165
  resp = await client.post(
174
166
  registration_endpoint,
175
- json={
176
- "client_name": app_name,
177
- "grant_types": ["authorization_code", "refresh_token"],
178
- "enforce_pkce": True,
179
- "all_users_entitled": True,
180
- "redirect_uris": [REDIRECT_URI],
181
- },
167
+ json={"client_name": "Agent Stack CLI", "redirect_uris": [REDIRECT_URI]},
182
168
  )
183
169
  resp.raise_for_status()
184
170
  data = resp.json()
185
171
  client_id = data["client_id"]
186
172
  client_secret = data["client_secret"]
187
- registration_token = data["registration_access_token"]
188
- except Exception as e:
189
- if resp:
190
- try:
191
- error_details = resp.json()
192
- console.warning(
193
- f"error: {error_details['error']} error description: {error_details['error_description']}"
194
- )
195
-
196
- except Exception:
197
- console.info("no parsable json response.")
198
- console.warning(f" Dynamic client registration failed. Proceed with manual input. {e!s}")
173
+ except Exception:
174
+ console.warning("Dynamic client registration failed. Proceed with manual input.")
199
175
 
200
176
  if not client_id:
201
177
  client_id = await inquirer.text( # type: ignore
@@ -232,7 +208,6 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
232
208
 
233
209
  code = await _wait_for_auth_code()
234
210
  async with httpx.AsyncClient() as client:
235
- token_resp = None
236
211
  try:
237
212
  token_resp = await client.post(
238
213
  oidc["token_endpoint"],
@@ -248,15 +223,6 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
248
223
  token_resp.raise_for_status()
249
224
  token = token_resp.json()
250
225
  except Exception as e:
251
- if resp:
252
- try:
253
- error_details = resp.json()
254
- console.warning(
255
- f"error: {error_details['error']} error description: {error_details['error_description']}"
256
- )
257
- except Exception:
258
- console.info("no parsable json response.")
259
-
260
226
  raise RuntimeError(f"Token request failed: {e}") from e
261
227
 
262
228
  if not token:
@@ -268,7 +234,6 @@ async def server_login(server: typing.Annotated[str | None, typer.Argument()] =
268
234
  client_id=client_id,
269
235
  client_secret=client_secret,
270
236
  token=token,
271
- registration_token=registration_token,
272
237
  )
273
238
 
274
239
  config.auth_manager.active_server = server
@@ -283,7 +248,7 @@ async def server_logout(
283
248
  typer.Option(),
284
249
  ] = False,
285
250
  ):
286
- await config.auth_manager.clear_auth_token(all=all)
251
+ config.auth_manager.clear_auth_token(all=all)
287
252
  console.success("You have been logged out.")
288
253
 
289
254
 
@@ -65,7 +65,7 @@ class Configuration(pydantic_settings.BaseSettings):
65
65
  sys.exit(1)
66
66
  async with use_platform_client(
67
67
  auth=("admin", self.admin_password.get_secret_value()) if self.admin_password else None,
68
- auth_token=await self.auth_manager.load_auth_token(),
68
+ auth_token=self.auth_manager.load_auth_token(),
69
69
  base_url=self.auth_manager.active_server + "/",
70
70
  ) as client:
71
71
  yield client
@@ -5,7 +5,6 @@ import contextlib
5
5
  import functools
6
6
  import json
7
7
  import os
8
- import re
9
8
  import subprocess
10
9
  import sys
11
10
  from collections.abc import AsyncIterator
@@ -27,7 +26,7 @@ from jsf import JSF
27
26
  from prompt_toolkit import PromptSession
28
27
  from prompt_toolkit.shortcuts import CompleteStyle
29
28
  from pydantic import BaseModel
30
- from rich.console import Capture, Console
29
+ from rich.console import Capture
31
30
  from rich.text import Text
32
31
 
33
32
  from agentstack_cli.configuration import Configuration
@@ -297,7 +296,7 @@ def verbosity(verbose: bool, show_success_status: bool = True):
297
296
  SHOW_SUCCESS_STATUS.reset(token_command_status)
298
297
 
299
298
 
300
- def print_log(line, ansi_mode=False, out_console: Console | None = None):
299
+ def print_log(line, ansi_mode=False):
301
300
  if "error" in line:
302
301
 
303
302
  class CustomError(Exception): ...
@@ -310,28 +309,6 @@ def print_log(line, ansi_mode=False, out_console: Console | None = None):
310
309
  return Text.from_ansi(text) if ansi_mode else text
311
310
 
312
311
  if line["stream"] == "stderr":
313
- (out_console or err_console).print(decode(line["message"]))
312
+ err_console.print(decode(line["message"]))
314
313
  elif line["stream"] == "stdout":
315
- (out_console or console).print(decode(line["message"]))
316
-
317
-
318
- def is_github_url(url: str) -> bool:
319
- """This pattern is taken from agentstack_server.utils.github.GithubUrl, make sure to keep it in sync"""
320
-
321
- pattern = r"""
322
- ^
323
- (?:git\+)? # Optional git+ prefix
324
- https?://(?P<host>github(?:\.[^/]+)+)/ # GitHub host (github.com or github.enterprise.com)
325
- (?P<org>[^/]+)/ # Organization
326
- (?P<repo>
327
- (?: # Non-capturing group for repo name
328
- (?!\.git(?:$|[@#])) # Negative lookahead for .git at end or followed by @#
329
- [^/@#] # Any char except /@#
330
- )+ # One or more of these chars
331
- )
332
- (?:\.git)? # Optional .git suffix
333
- (?:@(?P<version>[^#]+))? # Optional version after @
334
- (?:\#path=(?P<path>.+))? # Optional path after #path=
335
- $
336
- """
337
- return bool(re.match(pattern, url, re.VERBOSE))
314
+ console.print(decode(line["message"]))
@@ -1,241 +0,0 @@
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()