agentstack-cli 0.6.0rc1__py3-none-manylinux_2_34_aarch64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import logging
5
+ import re
6
+ import typing
7
+ from copy import deepcopy
8
+
9
+ import typer
10
+
11
+ import agentstack_cli.commands.agent
12
+ import agentstack_cli.commands.build
13
+ import agentstack_cli.commands.connector
14
+ import agentstack_cli.commands.model
15
+ import agentstack_cli.commands.platform
16
+ import agentstack_cli.commands.self
17
+ import agentstack_cli.commands.server
18
+
19
+ # import agentstack_cli.commands.user
20
+ from agentstack_cli.async_typer import AsyncTyper
21
+ from agentstack_cli.configuration import Configuration
22
+
23
+ logging.basicConfig(level=logging.INFO if Configuration().debug else logging.FATAL)
24
+ logging.getLogger("httpx").setLevel(logging.WARNING) # not sure why this is necessary
25
+
26
+
27
+ HELP_TEXT = """\
28
+ Usage: agentstack [OPTIONS] COMMAND [ARGS]...
29
+
30
+ ╭─ Getting Started ──────────────────────────────────────────────────────────╮
31
+ │ ui Launch the web interface │
32
+ │ list View all available agents │
33
+ │ info Show agent details │
34
+ │ run Run an agent interactively │
35
+ ╰────────────────────────────────────────────────────────────────────────────╯
36
+
37
+ ╭─ Agent Management [Admin only] ────────────────────────────────────────────╮
38
+ │ add Install an agent (Docker, GitHub) │
39
+ │ remove Uninstall an agent │
40
+ │ update Update an agent │
41
+ │ logs Stream agent execution logs │
42
+ │ env Manage agent environment variables │
43
+ │ build Build an agent remotely │
44
+ │ client-side-build Build an agent container image locally │
45
+ ╰────────────────────────────────────────────────────────────────────────────╯
46
+
47
+ ╭─ Platform & Configuration ─────────────────────────────────────────────────╮
48
+ | connector Manage connectors to external services │
49
+ │ model Configure 15+ LLM providers [Admin only] │
50
+ │ platform Start, stop, or delete local platform [Local only] │
51
+ │ server Connect to remote Agent Stack servers │
52
+ │ user Manage users and roles [Admin only] │
53
+ │ self version Show Agent Stack CLI and Platform version │
54
+ │ self upgrade Upgrade Agent Stack CLI and Platform [Local only] │
55
+ │ self uninstall Uninstall Agent Stack CLI and Platform [Local only] │
56
+ ╰────────────────────────────────────────────────────────────────────────────╯
57
+
58
+ ╭─ Options ──────────────────────────────────────────────────────────────────╮
59
+ │ --help Show this help message │
60
+ │ --show-completion Show tab completion script │
61
+ │ --install-completion Enable tab completion for commands │
62
+ ╰────────────────────────────────────────────────────────────────────────────╯
63
+ """
64
+
65
+
66
+ app = AsyncTyper()
67
+
68
+
69
+ @app.callback(invoke_without_command=True)
70
+ def main(
71
+ ctx: typer.Context,
72
+ help: bool = typer.Option(False, "--help", help="Show this message and exit."),
73
+ ):
74
+ if help or ctx.invoked_subcommand is None:
75
+ typer.echo(HELP_TEXT)
76
+ raise typer.Exit()
77
+
78
+
79
+ app.add_typer(
80
+ agentstack_cli.commands.model.app, name="model", no_args_is_help=True, help="Manage model providers. [Admin only]"
81
+ )
82
+ app.add_typer(
83
+ agentstack_cli.commands.agent.app,
84
+ name="agent",
85
+ no_args_is_help=True,
86
+ help="Manage agents. Some commands are [Admin only].",
87
+ )
88
+ app.add_typer(
89
+ agentstack_cli.commands.connector.app,
90
+ name="connector",
91
+ no_args_is_help=True,
92
+ help="Manage connectors to external services.",
93
+ )
94
+ app.add_typer(
95
+ agentstack_cli.commands.platform.app,
96
+ name="platform",
97
+ no_args_is_help=True,
98
+ help="Manage Agent Stack platform. [Local only]",
99
+ )
100
+ app.add_typer(agentstack_cli.commands.build.app, name="", no_args_is_help=True, help="Build agent images.")
101
+ app.add_typer(
102
+ agentstack_cli.commands.server.app,
103
+ name="server",
104
+ no_args_is_help=True,
105
+ help="Manage Agent Stack servers and authentication.",
106
+ )
107
+ app.add_typer(
108
+ agentstack_cli.commands.self.app,
109
+ name="self",
110
+ no_args_is_help=True,
111
+ help="Manage Agent Stack installation.",
112
+ hidden=True,
113
+ )
114
+ # TODO: Implement keycloak integration
115
+ # app.add_typer(
116
+ # agentstack_cli.commands.user.app,
117
+ # name="user",
118
+ # no_args_is_help=True,
119
+ # help="Manage users. [Admin only]",
120
+ # )
121
+
122
+
123
+ agent_alias = deepcopy(agentstack_cli.commands.agent.app)
124
+ for cmd in agent_alias.registered_commands:
125
+ cmd.rich_help_panel = "Agent commands"
126
+
127
+ app.add_typer(agent_alias, name="", no_args_is_help=True)
128
+
129
+
130
+ @app.command("version")
131
+ async def version(
132
+ verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
133
+ ):
134
+ """Print version of the Agent Stack CLI."""
135
+ import agentstack_cli.commands.self
136
+
137
+ await agentstack_cli.commands.self.version(verbose=verbose)
138
+
139
+
140
+ @app.command("ui")
141
+ async def ui():
142
+ """Launch the graphical interface."""
143
+ import webbrowser
144
+
145
+ import agentstack_cli.commands.model
146
+
147
+ await agentstack_cli.commands.model.ensure_llm_provider()
148
+
149
+ config = Configuration()
150
+ active_server = config.auth_manager.active_server
151
+
152
+ if active_server:
153
+ if re.search(r"(localhost|127\.0\.0\.1):8333", active_server):
154
+ ui_url = re.sub(r":8333", ":8334", active_server)
155
+ else:
156
+ ui_url = active_server
157
+ else:
158
+ ui_url = "http://localhost:8334"
159
+
160
+ webbrowser.open(ui_url)
161
+
162
+
163
+ if __name__ == "__main__":
164
+ app()
agentstack_cli/api.py ADDED
@@ -0,0 +1,160 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ import json
4
+ import logging
5
+ import re
6
+ import urllib
7
+ import urllib.parse
8
+ from collections.abc import AsyncIterator
9
+ from contextlib import asynccontextmanager
10
+ from datetime import timedelta
11
+ from textwrap import indent
12
+ from typing import Any
13
+
14
+ import httpx
15
+ import openai
16
+ import pydantic
17
+ from a2a.client import A2AClientHTTPError, Client, ClientConfig, ClientFactory
18
+ from a2a.types import AgentCard
19
+ from agentstack_sdk.platform.context import ContextToken
20
+ from httpx import HTTPStatusError
21
+ from httpx._types import RequestFiles
22
+
23
+ from agentstack_cli import configuration
24
+ from agentstack_cli.configuration import Configuration
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ config = Configuration()
29
+
30
+ API_BASE_URL = "api/v1/"
31
+
32
+
33
+ async def api_request(
34
+ method: str,
35
+ path: str,
36
+ json: dict | None = None,
37
+ files: RequestFiles | None = None,
38
+ params: dict[str, Any] | None = None,
39
+ use_auth: bool = True,
40
+ ) -> dict | None:
41
+ """Make an API request to the server."""
42
+ async with configuration.use_platform_client() as client:
43
+ response = await client.request(
44
+ method,
45
+ urllib.parse.urljoin(API_BASE_URL, path),
46
+ json=json,
47
+ files=files,
48
+ params=params,
49
+ timeout=60,
50
+ headers=(
51
+ {"Authorization": f"Bearer {token}"}
52
+ if use_auth and (token := await config.auth_manager.load_auth_token())
53
+ else {}
54
+ ),
55
+ )
56
+ if response.is_error:
57
+ error = ""
58
+ try:
59
+ error = response.json()
60
+ error = error.get("detail", str(error))
61
+ except Exception:
62
+ response.raise_for_status()
63
+ if response.status_code == 401:
64
+ message = f'{error}\nexport AGENTSTACK__ADMIN_PASSWORD="<PASSWORD>" to set the admin password.'
65
+ raise HTTPStatusError(message=message, request=response.request, response=response)
66
+ raise HTTPStatusError(message=error, request=response.request, response=response)
67
+ if response.content:
68
+ return response.json()
69
+
70
+
71
+ async def api_stream(
72
+ method: str,
73
+ path: str,
74
+ json: dict | None = None,
75
+ params: dict[str, Any] | None = None,
76
+ use_auth: bool = True,
77
+ ) -> AsyncIterator[dict[str, Any]]:
78
+ """Make a streaming API request to the server."""
79
+ import json as jsonlib
80
+
81
+ async with (
82
+ configuration.use_platform_client() as client,
83
+ client.stream(
84
+ method,
85
+ urllib.parse.urljoin(API_BASE_URL, path),
86
+ json=json,
87
+ params=params,
88
+ timeout=timedelta(hours=1).total_seconds(),
89
+ headers=(
90
+ {"Authorization": f"Bearer {token}"}
91
+ if use_auth and (token := await config.auth_manager.load_auth_token())
92
+ else {}
93
+ ),
94
+ ) as response,
95
+ ):
96
+ response: httpx.Response
97
+ if response.is_error:
98
+ error = ""
99
+ try:
100
+ [error] = [jsonlib.loads(message) async for message in response.aiter_text()]
101
+ error = error.get("detail", str(error))
102
+ except Exception:
103
+ response.raise_for_status()
104
+ raise HTTPStatusError(message=error, request=response.request, response=response)
105
+ async for line in response.aiter_lines():
106
+ if line:
107
+ yield jsonlib.loads(re.sub("^data:", "", line).strip())
108
+
109
+
110
+ async def fetch_server_version() -> str | None:
111
+ """Fetch server version from OpenAPI schema."""
112
+
113
+ class OpenAPIInfo(pydantic.BaseModel):
114
+ version: str
115
+
116
+ class OpenAPISchema(pydantic.BaseModel):
117
+ info: OpenAPIInfo
118
+
119
+ try:
120
+ response = await api_request("GET", "openapi.json", use_auth=False)
121
+ if not response:
122
+ return None
123
+ schema = OpenAPISchema.model_validate(response)
124
+ return schema.info.version
125
+ except Exception as e:
126
+ logger.warning("Failed to fetch server version: %s", e)
127
+ return None
128
+
129
+
130
+ @asynccontextmanager
131
+ async def a2a_client(agent_card: AgentCard, context_token: ContextToken) -> AsyncIterator[Client]:
132
+ try:
133
+ async with httpx.AsyncClient(
134
+ headers={"Authorization": f"Bearer {context_token.token.get_secret_value()}"},
135
+ follow_redirects=True,
136
+ timeout=timedelta(hours=1).total_seconds(),
137
+ ) as httpx_client:
138
+ yield ClientFactory(ClientConfig(httpx_client=httpx_client, use_client_preference=True)).create(
139
+ card=agent_card
140
+ )
141
+ except A2AClientHTTPError as ex:
142
+ card_data = json.dumps(
143
+ agent_card.model_dump(include={"url", "additional_interfaces", "preferred_transport"}), indent=2
144
+ )
145
+ raise RuntimeError(
146
+ f"The agent is not reachable, please check that the agent card is configured properly.\n"
147
+ f"Agent connection info:\n{indent(card_data, prefix=' ')}\n"
148
+ "Full Error:\n"
149
+ f"{indent(str(ex), prefix=' ')}"
150
+ ) from ex
151
+
152
+
153
+ @asynccontextmanager
154
+ async def openai_client() -> AsyncIterator[openai.AsyncOpenAI]:
155
+ async with Configuration().use_platform_client() as platform_client:
156
+ yield openai.AsyncOpenAI(
157
+ api_key=platform_client.headers.get("Authorization", "").removeprefix("Bearer ") or "dummy",
158
+ base_url=urllib.parse.urljoin(str(platform_client.base_url), urllib.parse.urljoin(API_BASE_URL, "openai")),
159
+ default_headers=platform_client.headers,
160
+ )
@@ -0,0 +1,113 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ import asyncio
5
+ import functools
6
+ import inspect
7
+ import re
8
+ import sys
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+
12
+ import rich.text
13
+ import typer
14
+ from rich.console import RenderResult
15
+ from rich.markdown import Heading, Markdown
16
+ from rich.table import Table
17
+ from typer.core import TyperGroup
18
+
19
+ from agentstack_cli.configuration import Configuration
20
+ from agentstack_cli.console import console, err_console
21
+ from agentstack_cli.utils import extract_messages, format_error
22
+
23
+ DEBUG = Configuration().debug
24
+
25
+
26
+ class _LeftAlignedHeading(Heading):
27
+ def __rich_console__(self, *args, **kwargs) -> RenderResult:
28
+ for elem in super().__rich_console__(*args, **kwargs):
29
+ if isinstance(elem, rich.text.Text):
30
+ elem.justify = "left"
31
+ yield elem
32
+
33
+
34
+ Markdown.elements["heading_open"] = _LeftAlignedHeading
35
+
36
+
37
+ @contextmanager
38
+ def create_table(*args, no_wrap: bool = True, **kwargs) -> Iterator[Table]:
39
+ table = Table(*args, **kwargs, box=None, pad_edge=False, width=console.width, show_header=True)
40
+ yield table
41
+ for column in table.columns:
42
+ column.no_wrap = no_wrap
43
+ column.overflow = "ellipsis"
44
+ assert isinstance(column.header, str)
45
+ column.header = column.header.upper()
46
+
47
+ if not table.rows:
48
+ table._render = lambda *args, **kwargs: [rich.text.Text("<No items found>", style="italic")]
49
+
50
+
51
+ class AliasGroup(TyperGroup):
52
+ """Taken from https://github.com/fastapi/typer/issues/132#issuecomment-2417492805"""
53
+
54
+ _CMD_SPLIT_P = re.compile(r" ?[,|] ?")
55
+
56
+ def get_command(self, ctx, cmd_name):
57
+ cmd_name = self._group_cmd_name(cmd_name)
58
+ return super().get_command(ctx, cmd_name)
59
+
60
+ def _group_cmd_name(self, default_name):
61
+ for cmd in self.commands.values():
62
+ name = cmd.name
63
+ if name and default_name in self._CMD_SPLIT_P.split(name):
64
+ return name
65
+ return default_name
66
+
67
+
68
+ class AsyncTyper(typer.Typer):
69
+ def __init__(self, *args, **kwargs):
70
+ kwargs["cls"] = kwargs.get("cls", AliasGroup)
71
+ super().__init__(*args, **kwargs)
72
+
73
+ def command(self, *args, **kwargs):
74
+ parent_decorator = super().command(*args, **kwargs)
75
+
76
+ def decorator(f):
77
+ @functools.wraps(f)
78
+ def wrapped_f(*args, **kwargs):
79
+ try:
80
+ if inspect.iscoroutinefunction(f):
81
+ return asyncio.run(f(*args, **kwargs))
82
+ else:
83
+ return f(*args, **kwargs)
84
+ except* Exception as ex:
85
+ is_permission_error = False
86
+ is_connect_error = False
87
+ for exc_type, message in extract_messages(ex):
88
+ err_console.print(format_error(exc_type, message))
89
+ is_connect_error = is_connect_error or exc_type in ["ConnectionError", "ConnectError"]
90
+ is_permission_error = is_permission_error or (
91
+ exc_type == "HTTPStatusError" and "403" in message
92
+ )
93
+ err_console.print()
94
+ if is_connect_error:
95
+ err_console.hint(
96
+ "Start the Agent Stack platform using: [green]agentstack platform start[/green]. If that does not help, run [green]agentstack platform delete[/green] to clean up, then [green]agentstack platform start[/green] again."
97
+ )
98
+ elif is_permission_error:
99
+ err_console.hint(
100
+ "This command requires higher permissions than your account currently has. Contact your administrator for assistance."
101
+ )
102
+ else:
103
+ err_console.hint(
104
+ "Are you having consistent problems? If so, try these troubleshooting steps: [green]agentstack platform delete[/green] to remove the platform, and [green]agentstack platform start[/green] to recreate it."
105
+ )
106
+ if DEBUG:
107
+ raise
108
+ sys.exit(1)
109
+
110
+ parent_decorator(wrapped_f)
111
+ return f
112
+
113
+ return decorator
@@ -0,0 +1,242 @@
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
+
84
+ async with httpx.AsyncClient(headers={"Accept": "application/json"}) as client:
85
+ resp = None
86
+ try:
87
+ resp = await client.get(f"{auth_server}/.well-known/openid-configuration")
88
+ resp.raise_for_status()
89
+ oidc = resp.json()
90
+ except Exception as e:
91
+ if resp:
92
+ error_details = resp.json()
93
+ print(f"error: {error_details['error']} error description: {error_details['error_description']}")
94
+ raise RuntimeError(f"OIDC discovery failed: {e}") from e
95
+
96
+ token_endpoint = oidc["token_endpoint"]
97
+ try:
98
+ client_id = (
99
+ self._auth.servers[self._auth.active_server or ""].authorization_servers[auth_server].client_id
100
+ )
101
+ client_secret = (
102
+ self._auth.servers[self._auth.active_server or ""].authorization_servers[auth_server].client_secret
103
+ )
104
+ resp = await client.post(
105
+ f"{token_endpoint}",
106
+ data={
107
+ "grant_type": "refresh_token",
108
+ "refresh_token": token.refresh_token,
109
+ "scope": token.scope,
110
+ "client_id": client_id,
111
+ }
112
+ | ({"client_secret": client_secret} if client_secret else {}),
113
+ )
114
+ resp.raise_for_status()
115
+ new_token = resp.json()
116
+ except Exception as e:
117
+ if resp:
118
+ error_details = resp.json()
119
+ print(f"error: {error_details['error']} error description: {error_details['error_description']}")
120
+ raise RuntimeError(f"Failed to refresh token: {e}") from e
121
+ self.save_auth_token(
122
+ self._auth.active_server or "",
123
+ self._auth.active_auth_server or "",
124
+ self._auth.servers[self._auth.active_server or ""].authorization_servers[auth_server].client_id or "",
125
+ self._auth.servers[self._auth.active_server or ""].authorization_servers[auth_server].client_secret
126
+ or "",
127
+ token=new_token,
128
+ )
129
+ return new_token
130
+
131
+ async def load_auth_token(self) -> str | None:
132
+ active_res = self._auth.active_server
133
+ active_auth_server = self._auth.active_auth_server
134
+ if not active_res or not active_auth_server:
135
+ return None
136
+ server = self._auth.servers.get(active_res)
137
+ if not server:
138
+ return None
139
+
140
+ auth_server = server.authorization_servers.get(active_auth_server)
141
+ if not auth_server or not auth_server.token:
142
+ return None
143
+
144
+ if (auth_server.token.expires_at or 0) - 60 < time.time():
145
+ new_token = await self.exchange_refresh_token(active_auth_server, auth_server.token)
146
+ if new_token:
147
+ return new_token["access_token"]
148
+ return None
149
+
150
+ return auth_server.token.access_token
151
+
152
+ async def deregister_client(self, auth_server, client_id, registration_token) -> None:
153
+ async with httpx.AsyncClient(headers={"Accept": "application/json"}) as client:
154
+ resp = None
155
+ try:
156
+ resp = await client.get(f"{auth_server}/.well-known/openid-configuration")
157
+ resp.raise_for_status()
158
+ oidc = resp.json()
159
+ registration_endpoint = oidc["registration_endpoint"]
160
+ except Exception as e:
161
+ if resp:
162
+ error_details = resp.json()
163
+ print(f"error: {error_details['error']} error description: {error_details['error_description']}")
164
+ raise RuntimeError(f"OIDC discovery failed: {e}") from e
165
+
166
+ try:
167
+ if client_id is not None and client_id != "" and registration_token is not None:
168
+ headers = {"authorization": f"bearer {registration_token}"}
169
+ resp = await client.delete(f"{registration_endpoint}/{client_id}", headers=headers)
170
+ resp.raise_for_status()
171
+
172
+ except Exception as e:
173
+ if resp:
174
+ error_details = resp.json()
175
+ print(f"error: {error_details['error']} error description: {error_details['error_description']}")
176
+ raise RuntimeError(f"Dynamic client de-registration failed. {e}") from e
177
+
178
+ async def clear_auth_token(self, all: bool = False) -> None:
179
+ if all:
180
+ for server in self._auth.servers:
181
+ for auth_server in self._auth.servers[server].authorization_servers:
182
+ await self.deregister_client(
183
+ auth_server,
184
+ self._auth.servers[server].authorization_servers[auth_server].client_id,
185
+ self._auth.servers[server].authorization_servers[auth_server].registration_token,
186
+ )
187
+
188
+ self._auth.servers = defaultdict(Server)
189
+ else:
190
+ if self._auth.active_server and self._auth.active_auth_server:
191
+ if (
192
+ self._auth.servers[self._auth.active_server]
193
+ .authorization_servers[self._auth.active_auth_server]
194
+ .client_id
195
+ ):
196
+ await self.deregister_client(
197
+ self._auth.active_auth_server,
198
+ self._auth.servers[self._auth.active_server]
199
+ .authorization_servers[self._auth.active_auth_server]
200
+ .client_id,
201
+ self._auth.servers[self._auth.active_server]
202
+ .authorization_servers[self._auth.active_auth_server]
203
+ .registration_token,
204
+ )
205
+ del self._auth.servers[self._auth.active_server].authorization_servers[self._auth.active_auth_server]
206
+ if self._auth.active_server and not self._auth.servers[self._auth.active_server].authorization_servers:
207
+ del self._auth.servers[self._auth.active_server]
208
+ self._auth.active_server = None
209
+ self._auth.active_auth_server = None
210
+ self._save()
211
+
212
+ def get_server(self, server: str) -> Server | None:
213
+ return self._auth.servers.get(server)
214
+
215
+ @property
216
+ def servers(self) -> list[str]:
217
+ return list(self._auth.servers.keys())
218
+
219
+ @property
220
+ def active_server(self) -> str | None:
221
+ return self._auth.active_server
222
+
223
+ @active_server.setter
224
+ def active_server(self, server: str | None) -> None:
225
+ if server is not None and server not in self._auth.servers:
226
+ raise ValueError(f"Server {server} not found")
227
+ self._auth.active_server = server
228
+ self._save()
229
+
230
+ @property
231
+ def active_auth_server(self) -> str | None:
232
+ return self._auth.active_auth_server
233
+
234
+ @active_auth_server.setter
235
+ def active_auth_server(self, auth_server: str | None) -> None:
236
+ if auth_server is not None and (
237
+ self._auth.active_server not in self._auth.servers
238
+ or auth_server not in self._auth.servers[self._auth.active_server].authorization_servers
239
+ ):
240
+ raise ValueError(f"Auth server {auth_server} not found in active server")
241
+ self._auth.active_auth_server = auth_server
242
+ self._save()
@@ -0,0 +1,3 @@
1
+ # Copyright 2025 © BeeAI a Series of LF Projects, LLC
2
+ # SPDX-License-Identifier: Apache-2.0
3
+