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.
- agentstack_cli/__init__.py +164 -0
- agentstack_cli/api.py +160 -0
- agentstack_cli/async_typer.py +113 -0
- agentstack_cli/auth_manager.py +242 -0
- agentstack_cli/commands/__init__.py +3 -0
- agentstack_cli/commands/agent.py +1386 -0
- agentstack_cli/commands/build.py +222 -0
- agentstack_cli/commands/connector.py +301 -0
- agentstack_cli/commands/model.py +653 -0
- agentstack_cli/commands/platform/__init__.py +198 -0
- agentstack_cli/commands/platform/base_driver.py +217 -0
- agentstack_cli/commands/platform/lima_driver.py +277 -0
- agentstack_cli/commands/platform/wsl_driver.py +229 -0
- agentstack_cli/commands/self.py +213 -0
- agentstack_cli/commands/server.py +315 -0
- agentstack_cli/commands/user.py +87 -0
- agentstack_cli/configuration.py +79 -0
- agentstack_cli/console.py +25 -0
- agentstack_cli/data/.gitignore +2 -0
- agentstack_cli/data/helm-chart.tgz +0 -0
- agentstack_cli/data/lima-guestagent.Linux-aarch64.gz +0 -0
- agentstack_cli/data/limactl +0 -0
- agentstack_cli/utils.py +389 -0
- agentstack_cli-0.6.0rc1.dist-info/METADATA +107 -0
- agentstack_cli-0.6.0rc1.dist-info/RECORD +27 -0
- agentstack_cli-0.6.0rc1.dist-info/WHEEL +4 -0
- agentstack_cli-0.6.0rc1.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import typing
|
|
10
|
+
import uuid
|
|
11
|
+
from asyncio import CancelledError
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from datetime import timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import anyio
|
|
17
|
+
import anyio.abc
|
|
18
|
+
import typer
|
|
19
|
+
from a2a.utils import AGENT_CARD_WELL_KNOWN_PATH
|
|
20
|
+
from agentstack_sdk.platform import AddProvider, BuildConfiguration, Provider, UpdateProvider
|
|
21
|
+
from agentstack_sdk.platform.provider_build import ProviderBuild
|
|
22
|
+
from anyio import open_process
|
|
23
|
+
from httpx import AsyncClient, HTTPError
|
|
24
|
+
from tenacity import AsyncRetrying, retry_if_exception_type, stop_after_delay, wait_fixed
|
|
25
|
+
|
|
26
|
+
from agentstack_cli.async_typer import AsyncTyper
|
|
27
|
+
from agentstack_cli.console import console, err_console
|
|
28
|
+
from agentstack_cli.utils import (
|
|
29
|
+
announce_server_action,
|
|
30
|
+
capture_output,
|
|
31
|
+
confirm_server_action,
|
|
32
|
+
extract_messages,
|
|
33
|
+
print_log,
|
|
34
|
+
run_command,
|
|
35
|
+
status,
|
|
36
|
+
verbosity,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def find_free_port():
|
|
41
|
+
"""Get a random free port assigned by the OS."""
|
|
42
|
+
listener = await anyio.create_tcp_listener()
|
|
43
|
+
port = listener.extra(anyio.abc.SocketAttribute.local_address)[1]
|
|
44
|
+
await listener.aclose()
|
|
45
|
+
return port
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
app = AsyncTyper()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command("client-side-build")
|
|
52
|
+
async def client_side_build(
|
|
53
|
+
context: typing.Annotated[str, typer.Argument(help="Docker context for the agent")] = ".",
|
|
54
|
+
dockerfile: typing.Annotated[str | None, typer.Option(help="Use custom dockerfile path")] = None,
|
|
55
|
+
tag: typing.Annotated[str | None, typer.Option(help="Docker tag for the agent")] = None,
|
|
56
|
+
multi_platform: bool | None = False,
|
|
57
|
+
push: typing.Annotated[bool, typer.Option(help="Push the image to the target registry.")] = False,
|
|
58
|
+
import_image: typing.Annotated[
|
|
59
|
+
bool, typer.Option("--import/--no-import", is_flag=True, help="Import the image into Agent Stack platform")
|
|
60
|
+
] = True,
|
|
61
|
+
vm_name: typing.Annotated[str, typer.Option(hidden=True)] = "agentstack",
|
|
62
|
+
verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
|
|
63
|
+
):
|
|
64
|
+
"""Build agent locally using Docker. [Local only]"""
|
|
65
|
+
with verbosity(verbose):
|
|
66
|
+
await run_command(["which", "docker"], "Checking docker")
|
|
67
|
+
image_id = "agentstack-agent-build-tmp:latest"
|
|
68
|
+
port = await find_free_port()
|
|
69
|
+
dockerfile_args = ("-f", dockerfile) if dockerfile else ()
|
|
70
|
+
|
|
71
|
+
await run_command(
|
|
72
|
+
["docker", "build", context, *dockerfile_args, "-t", image_id],
|
|
73
|
+
"Building agent image",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
agent_card = None
|
|
77
|
+
|
|
78
|
+
container_id = str(uuid.uuid4())
|
|
79
|
+
|
|
80
|
+
with status("Extracting agent metadata"):
|
|
81
|
+
async with (
|
|
82
|
+
await open_process(
|
|
83
|
+
f"docker run --name {container_id} --rm -p {port}:8000 -e HOST=0.0.0.0 -e PORT=8000 {image_id}",
|
|
84
|
+
) as process,
|
|
85
|
+
):
|
|
86
|
+
async with capture_output(process) as task_group:
|
|
87
|
+
try:
|
|
88
|
+
async for attempt in AsyncRetrying(
|
|
89
|
+
stop=stop_after_delay(timedelta(seconds=30)),
|
|
90
|
+
wait=wait_fixed(timedelta(seconds=0.5)),
|
|
91
|
+
retry=retry_if_exception_type(HTTPError),
|
|
92
|
+
reraise=True,
|
|
93
|
+
):
|
|
94
|
+
with attempt:
|
|
95
|
+
async with AsyncClient() as client:
|
|
96
|
+
resp = await client.get(
|
|
97
|
+
f"http://localhost:{port}{AGENT_CARD_WELL_KNOWN_PATH}", timeout=1
|
|
98
|
+
)
|
|
99
|
+
resp.raise_for_status()
|
|
100
|
+
agent_card = resp.json()
|
|
101
|
+
process.terminate()
|
|
102
|
+
with suppress(ProcessLookupError):
|
|
103
|
+
process.kill()
|
|
104
|
+
except BaseException as ex:
|
|
105
|
+
raise RuntimeError(f"Failed to build agent: {extract_messages(ex)}") from ex
|
|
106
|
+
finally:
|
|
107
|
+
task_group.cancel_scope.cancel()
|
|
108
|
+
with suppress(BaseException):
|
|
109
|
+
await run_command(["docker", "kill", container_id], "Killing container")
|
|
110
|
+
with suppress(ProcessLookupError):
|
|
111
|
+
process.kill()
|
|
112
|
+
|
|
113
|
+
context_hash = hashlib.sha256((context + (dockerfile or "")).encode()).hexdigest()[:6]
|
|
114
|
+
context_shorter = re.sub(r"https?://", "", context).replace(r".git", "")
|
|
115
|
+
context_shorter = re.sub(r"[^a-zA-Z0-9_-]+", "-", context_shorter)[:32].lstrip("-") or "provider"
|
|
116
|
+
tag = (tag or f"agentstack-registry-svc.default:5001/{context_shorter}-{context_hash}:latest").lower()
|
|
117
|
+
await run_command(
|
|
118
|
+
command=[
|
|
119
|
+
*(
|
|
120
|
+
["docker", "buildx", "build", "--platform=linux/amd64,linux/arm64"]
|
|
121
|
+
if multi_platform
|
|
122
|
+
else ["docker", "build"]
|
|
123
|
+
),
|
|
124
|
+
"--push" if push else "--load",
|
|
125
|
+
context,
|
|
126
|
+
*dockerfile_args,
|
|
127
|
+
"-t",
|
|
128
|
+
tag,
|
|
129
|
+
f"--label=beeai.dev.agent.json={base64.b64encode(json.dumps(agent_card).encode()).decode()}",
|
|
130
|
+
],
|
|
131
|
+
message="Adding agent labels to container",
|
|
132
|
+
check=True,
|
|
133
|
+
)
|
|
134
|
+
console.success(f"Successfully built agent: {tag}")
|
|
135
|
+
if import_image:
|
|
136
|
+
from agentstack_cli.commands.platform import get_driver
|
|
137
|
+
|
|
138
|
+
if "agentstack-registry-svc.default" not in tag:
|
|
139
|
+
source_tag = tag
|
|
140
|
+
tag = re.sub("^[^/]*/", "agentstack-registry-svc.default:5001/", tag)
|
|
141
|
+
await run_command(["docker", "tag", source_tag, tag], "Tagging image")
|
|
142
|
+
|
|
143
|
+
driver = get_driver(vm_name=vm_name)
|
|
144
|
+
|
|
145
|
+
if (await driver.status()) != "running":
|
|
146
|
+
console.error("Agent Stack platform is not running.")
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
|
|
149
|
+
await driver.import_image_to_internal_registry(tag)
|
|
150
|
+
console.success(
|
|
151
|
+
"Agent was imported to the agent stack internal registry.\n"
|
|
152
|
+
+ f"You can add it using [blue]agentstack add {tag}[/blue]"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return tag, agent_card
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _server_side_build(
|
|
159
|
+
github_url: str,
|
|
160
|
+
dockerfile: str | None = None,
|
|
161
|
+
replace: str | None = None,
|
|
162
|
+
add: bool = False,
|
|
163
|
+
verbose: bool = False,
|
|
164
|
+
) -> ProviderBuild:
|
|
165
|
+
build = None
|
|
166
|
+
from agentstack_cli.commands.agent import select_provider
|
|
167
|
+
from agentstack_cli.configuration import Configuration
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
if replace and add:
|
|
171
|
+
raise ValueError("Cannot specify both replace and add options.")
|
|
172
|
+
|
|
173
|
+
build_configuration = None
|
|
174
|
+
if dockerfile:
|
|
175
|
+
build_configuration = BuildConfiguration(dockerfile_path=Path(dockerfile))
|
|
176
|
+
|
|
177
|
+
async with Configuration().use_platform_client():
|
|
178
|
+
on_complete = None
|
|
179
|
+
if replace:
|
|
180
|
+
provider = select_provider(replace, await Provider.list())
|
|
181
|
+
on_complete = UpdateProvider(provider_id=uuid.UUID(provider.id))
|
|
182
|
+
elif add:
|
|
183
|
+
on_complete = AddProvider()
|
|
184
|
+
|
|
185
|
+
build = await ProviderBuild.create(
|
|
186
|
+
location=github_url,
|
|
187
|
+
on_complete=on_complete,
|
|
188
|
+
build_configuration=build_configuration,
|
|
189
|
+
)
|
|
190
|
+
with verbosity(verbose):
|
|
191
|
+
async for message in build.stream_logs():
|
|
192
|
+
print_log(message, ansi_mode=True, out_console=err_console)
|
|
193
|
+
return await build.get()
|
|
194
|
+
except (KeyboardInterrupt, CancelledError):
|
|
195
|
+
async with Configuration().use_platform_client():
|
|
196
|
+
if build:
|
|
197
|
+
await build.delete()
|
|
198
|
+
console.error("Build aborted.")
|
|
199
|
+
raise
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command("build")
|
|
203
|
+
async def server_side_build(
|
|
204
|
+
github_url: typing.Annotated[
|
|
205
|
+
str, typer.Argument(..., help="Github repository URL (public or private if supported by the platform instance)")
|
|
206
|
+
],
|
|
207
|
+
dockerfile: typing.Annotated[
|
|
208
|
+
str | None, typer.Option(help="Use custom dockerfile path, relative to github url sub-path")
|
|
209
|
+
] = None,
|
|
210
|
+
verbose: typing.Annotated[bool, typer.Option("-v", "--verbose", help="Show verbose output")] = False,
|
|
211
|
+
yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
|
|
212
|
+
):
|
|
213
|
+
"""Build agent from a GitHub repository in the platform. [Admin only]"""
|
|
214
|
+
|
|
215
|
+
url = announce_server_action(f"Starting build for '{github_url}' on")
|
|
216
|
+
await confirm_server_action("Proceed with building this agent on", url=url, yes=yes)
|
|
217
|
+
|
|
218
|
+
build = await _server_side_build(github_url=github_url, dockerfile=dockerfile, verbose=verbose)
|
|
219
|
+
|
|
220
|
+
console.success(
|
|
221
|
+
f"Agent built successfully, add it to the platform using: [green]agentstack add {build.destination}[/green]"
|
|
222
|
+
)
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# Copyright 2025 © BeeAI a Series of LF Projects, LLC
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
import asyncio
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
import pydantic
|
|
7
|
+
import typer
|
|
8
|
+
from agentstack_sdk.platform.connector import Connector, ConnectorState
|
|
9
|
+
from agentstack_sdk.platform.types import Metadata
|
|
10
|
+
from InquirerPy import inquirer
|
|
11
|
+
from InquirerPy.base.control import Choice
|
|
12
|
+
|
|
13
|
+
from agentstack_cli import configuration
|
|
14
|
+
from agentstack_cli.async_typer import AsyncTyper
|
|
15
|
+
from agentstack_cli.configuration import Configuration
|
|
16
|
+
from agentstack_cli.console import console
|
|
17
|
+
from agentstack_cli.utils import (
|
|
18
|
+
announce_server_action,
|
|
19
|
+
confirm_server_action,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app = AsyncTyper()
|
|
23
|
+
config = Configuration()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command("create")
|
|
27
|
+
async def create_connector(
|
|
28
|
+
url: typing.Annotated[str, typer.Argument(help="Agent location (public docker image or github url)")],
|
|
29
|
+
client_id: typing.Annotated[
|
|
30
|
+
str | None,
|
|
31
|
+
typer.Option("--client-id", help="Client ID for authentication, acquired from env if not supplied"),
|
|
32
|
+
] = None,
|
|
33
|
+
client_secret: typing.Annotated[
|
|
34
|
+
str | None,
|
|
35
|
+
typer.Option("--client-secret", help="Client secret for authentication, acquired from env if not supplied"),
|
|
36
|
+
] = None,
|
|
37
|
+
metadata: typing.Annotated[str | None, typer.Option("--metadata", help="Metadata as JSON string")] = None,
|
|
38
|
+
match_preset: typing.Annotated[
|
|
39
|
+
bool, typer.Option("--match-preset", help="Use preset configuration for given url if it exists")
|
|
40
|
+
] = True,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Create a connector to an external service."""
|
|
43
|
+
async with configuration.use_platform_client():
|
|
44
|
+
connector = await Connector.create(
|
|
45
|
+
url,
|
|
46
|
+
client_id=client_id if client_id else config.client_id,
|
|
47
|
+
client_secret=client_secret if client_secret else config.client_secret,
|
|
48
|
+
metadata=pydantic.TypeAdapter(Metadata).validate_json(metadata if metadata else "{}"),
|
|
49
|
+
match_preset=match_preset,
|
|
50
|
+
)
|
|
51
|
+
console.success(
|
|
52
|
+
f"Created connector for URL [blue]{connector.url}[/blue] with id: [green]{connector.id}[/green]\n"
|
|
53
|
+
f"Connector status: [yellow]{connector.state}[/yellow]"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def search_path_match_connectors(search_path: str, connectors: list[Connector]) -> list[Connector]:
|
|
58
|
+
return [
|
|
59
|
+
c for c in connectors if (search_path in str(c.id) or search_path.lower() in c.url.unicode_string().lower())
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def select_connectors_multi(
|
|
64
|
+
search_path: str, connectors: list[Connector], operation_name: str = "remove"
|
|
65
|
+
) -> list[Connector]:
|
|
66
|
+
"""Select multiple connectors matching the search path."""
|
|
67
|
+
connector_candidates = search_path_match_connectors(search_path, connectors)
|
|
68
|
+
if not connector_candidates:
|
|
69
|
+
raise ValueError(f"No matching connectors found for '{search_path}'")
|
|
70
|
+
|
|
71
|
+
if len(connector_candidates) == 1:
|
|
72
|
+
return connector_candidates
|
|
73
|
+
|
|
74
|
+
# Multiple matches - show selection menu
|
|
75
|
+
choices = [Choice(value=c, name=f"{c.url} - {c.id} ({c.state})") for c in connector_candidates]
|
|
76
|
+
|
|
77
|
+
selected_connectors = await inquirer.checkbox( # pyright: ignore[reportPrivateImportUsage]
|
|
78
|
+
message=f"Select connectors to {operation_name} (use ↑/↓ to navigate, Space to select):", choices=choices
|
|
79
|
+
).execute_async()
|
|
80
|
+
|
|
81
|
+
return selected_connectors or []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command("remove | rm | delete")
|
|
85
|
+
async def remove_connector(
|
|
86
|
+
search_path: typing.Annotated[
|
|
87
|
+
str, typer.Argument(help="Short ID or connector url, supports partial matching")
|
|
88
|
+
] = "",
|
|
89
|
+
yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
|
|
90
|
+
all: typing.Annotated[bool, typer.Option("--all", "-a", help="Remove all connectors without selection.")] = False,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Remove connectors."""
|
|
93
|
+
|
|
94
|
+
async def _delete_and_wait_for_completion(connector: Connector) -> None:
|
|
95
|
+
await connector.delete()
|
|
96
|
+
await connector.wait_for_deletion()
|
|
97
|
+
|
|
98
|
+
if search_path and all:
|
|
99
|
+
console.error(
|
|
100
|
+
"[red]Cannot specify both --all and a search path. Use --all to remove all connectors, or provide a search path for specific connectors.[/red]"
|
|
101
|
+
)
|
|
102
|
+
raise typer.Exit(1)
|
|
103
|
+
|
|
104
|
+
async with configuration.use_platform_client():
|
|
105
|
+
connectors_list = await Connector.list()
|
|
106
|
+
connectors = connectors_list.items
|
|
107
|
+
if len(connectors) == 0:
|
|
108
|
+
console.info("[yellow]No connectors found.[/yellow]")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
if all:
|
|
112
|
+
selected_connectors = connectors
|
|
113
|
+
else:
|
|
114
|
+
selected_connectors = await select_connectors_multi(search_path, connectors, operation_name="remove")
|
|
115
|
+
|
|
116
|
+
if not selected_connectors:
|
|
117
|
+
console.info("[yellow]No connectors selected, exiting.[/yellow]")
|
|
118
|
+
return
|
|
119
|
+
else:
|
|
120
|
+
connector_names = "\n".join([f" - {c.url} - {c.id}" for c in selected_connectors])
|
|
121
|
+
|
|
122
|
+
message = f"\n[bold]Selected connectors to remove:[/bold]\n{connector_names}\n from "
|
|
123
|
+
|
|
124
|
+
url = announce_server_action(message)
|
|
125
|
+
await confirm_server_action("Proceed with removing these connectors from", url=url, yes=yes)
|
|
126
|
+
|
|
127
|
+
with console.status("Removing connector(s)...", spinner="dots"):
|
|
128
|
+
delete_tasks = [_delete_and_wait_for_completion(connector) for connector in selected_connectors]
|
|
129
|
+
results = await asyncio.gather(*delete_tasks, return_exceptions=True)
|
|
130
|
+
|
|
131
|
+
# Check results for exceptions
|
|
132
|
+
successful_deletions = []
|
|
133
|
+
for connector, result in zip(selected_connectors, results, strict=True):
|
|
134
|
+
if isinstance(result, Exception):
|
|
135
|
+
console.error(f"[red]Failed to delete {connector.url}:[/red] {result}")
|
|
136
|
+
else:
|
|
137
|
+
successful_deletions.append(connector)
|
|
138
|
+
|
|
139
|
+
# Wait for successful deletions to complete
|
|
140
|
+
for connector in successful_deletions:
|
|
141
|
+
console.success(f"[green]Successfully deleted connector {connector.url}[/green]")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command("list")
|
|
145
|
+
async def list_connectors() -> None:
|
|
146
|
+
"""List all connectors."""
|
|
147
|
+
async with configuration.use_platform_client():
|
|
148
|
+
connectors = await Connector.list()
|
|
149
|
+
message = f"Found [green]{connectors.total_count}[/green] connectors"
|
|
150
|
+
if connectors.total_count > 0:
|
|
151
|
+
message += ":"
|
|
152
|
+
for item in connectors.items:
|
|
153
|
+
message += f"\n- {item.id}: {item.url} ({item.state})"
|
|
154
|
+
|
|
155
|
+
console.success(message)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.command("list-presets")
|
|
159
|
+
async def list_connector_presets() -> None:
|
|
160
|
+
"""List connector presets."""
|
|
161
|
+
async with configuration.use_platform_client():
|
|
162
|
+
presets = await Connector.presets()
|
|
163
|
+
message = f"Found [green]{presets.total_count}[/green] connector presets:"
|
|
164
|
+
for item in presets.items:
|
|
165
|
+
message += f"\n- {item}"
|
|
166
|
+
|
|
167
|
+
console.success(message)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def find_matching_connector(search_path: str, connectors: list[Connector]) -> Connector:
|
|
171
|
+
connector_candidates = search_path_match_connectors(search_path, connectors)
|
|
172
|
+
if len(connector_candidates) != 1:
|
|
173
|
+
message = f"Found {len(connector_candidates)} matching connectors"
|
|
174
|
+
connector_list = [f" - {c.url} - {c.id} ({c.state})" for c in connector_candidates]
|
|
175
|
+
connectors_detail = ":\n" + "\n".join(connector_list) if connector_list else ""
|
|
176
|
+
raise ValueError(message + connectors_detail)
|
|
177
|
+
[selected_connector] = connector_candidates
|
|
178
|
+
return selected_connector
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def select_connector(search_path: str) -> Connector | None:
|
|
182
|
+
connectors_list = await Connector.list()
|
|
183
|
+
connectors = connectors_list.items
|
|
184
|
+
if connectors_list.total_count == 0:
|
|
185
|
+
console.info("[yellow]No connectors found.[/yellow]")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
selected_connector = find_matching_connector(search_path, connectors)
|
|
190
|
+
return selected_connector
|
|
191
|
+
except ValueError as e:
|
|
192
|
+
console.error(e.__str__())
|
|
193
|
+
console.hint("Please refine your input to match exactly one connector id or url.")
|
|
194
|
+
raise typer.Exit(code=1) from None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.command("get")
|
|
198
|
+
async def get_connector(
|
|
199
|
+
search_path: typing.Annotated[str, typer.Argument(help="Short ID or connector url, supports partial matching")],
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Get connector details."""
|
|
202
|
+
async with configuration.use_platform_client():
|
|
203
|
+
selected_connector = await select_connector(search_path)
|
|
204
|
+
if not selected_connector:
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
connector = await Connector.get(selected_connector.id)
|
|
208
|
+
connector_data = connector.model_dump()
|
|
209
|
+
message = "Connector details:"
|
|
210
|
+
for key, value in connector_data.items():
|
|
211
|
+
if key in ["auth_request"]:
|
|
212
|
+
continue # Skip auth_request details
|
|
213
|
+
message += f"\n- {key}: {value}"
|
|
214
|
+
|
|
215
|
+
console.success(message)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@app.command("connect")
|
|
219
|
+
async def connect(
|
|
220
|
+
search_path: typing.Annotated[str, typer.Argument(help="Short ID or connector url, supports partial matching")],
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Connect a connector (e.g., start OAuth flow)."""
|
|
223
|
+
async with configuration.use_platform_client():
|
|
224
|
+
selected_connector = await select_connector(search_path)
|
|
225
|
+
if not selected_connector:
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
with console.status("Connecting connector...", spinner="dots"):
|
|
230
|
+
connector = await selected_connector.connect()
|
|
231
|
+
connector = await connector.wait_for_state(state=ConnectorState.connected)
|
|
232
|
+
|
|
233
|
+
console.success(
|
|
234
|
+
f"[green]Connector connected successfully:[/green] {connector.url} (state: {connector.state})"
|
|
235
|
+
)
|
|
236
|
+
except Exception as e:
|
|
237
|
+
console.error(f"[red]Failed to connect connector:[/red] {e}")
|
|
238
|
+
raise typer.Exit(code=1) from None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@app.command("disconnect")
|
|
242
|
+
async def disconnect(
|
|
243
|
+
search_path: typing.Annotated[
|
|
244
|
+
str, typer.Argument(help="Short ID or connector url, supports partial matching")
|
|
245
|
+
] = "",
|
|
246
|
+
yes: typing.Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation prompts.")] = False,
|
|
247
|
+
all: typing.Annotated[
|
|
248
|
+
bool, typer.Option("--all", "-a", help="Deisconnect all connectors without selection.")
|
|
249
|
+
] = False,
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Disconnect one or more connectors."""
|
|
252
|
+
|
|
253
|
+
async def _discionnect_and_wait_for_completion(connector: Connector) -> None:
|
|
254
|
+
await connector.disconnect()
|
|
255
|
+
await connector.wait_for_state(state=ConnectorState.disconnected)
|
|
256
|
+
|
|
257
|
+
if search_path and all:
|
|
258
|
+
console.error(
|
|
259
|
+
"[red]Cannot specify both --all and a search path. Use --all to remove all connectors, or provide a search path for specific connectors.[/red]"
|
|
260
|
+
)
|
|
261
|
+
raise typer.Exit(1)
|
|
262
|
+
|
|
263
|
+
async with configuration.use_platform_client():
|
|
264
|
+
connectors_list = await Connector.list()
|
|
265
|
+
connectors = connectors_list.items
|
|
266
|
+
if len(connectors) == 0:
|
|
267
|
+
console.info("[yellow]No connectors found.[/yellow]")
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
if all:
|
|
271
|
+
selected_connectors = connectors
|
|
272
|
+
else:
|
|
273
|
+
selected_connectors = await select_connectors_multi(search_path, connectors, operation_name="disconnect")
|
|
274
|
+
|
|
275
|
+
if not selected_connectors:
|
|
276
|
+
console.info("[yellow]No connectors selected, exiting.[/yellow]")
|
|
277
|
+
return
|
|
278
|
+
else:
|
|
279
|
+
connector_names = "\n".join([f" - {c.url} - {c.id}" for c in selected_connectors])
|
|
280
|
+
|
|
281
|
+
message = f"\n[bold]Selected connectors to disconnect:[/bold]\n{connector_names}\n from "
|
|
282
|
+
|
|
283
|
+
url = announce_server_action(message)
|
|
284
|
+
await confirm_server_action("Proceed with disconnecting these connectors from", url=url, yes=yes)
|
|
285
|
+
|
|
286
|
+
with console.status("Disconnecting connectors...", spinner="dots"):
|
|
287
|
+
disconnect_tasks = [_discionnect_and_wait_for_completion(connector) for connector in selected_connectors]
|
|
288
|
+
results = await asyncio.gather(*disconnect_tasks, return_exceptions=True)
|
|
289
|
+
|
|
290
|
+
# Check results for exceptions
|
|
291
|
+
successful_disconnections = []
|
|
292
|
+
for connector, result in zip(selected_connectors, results, strict=True):
|
|
293
|
+
if isinstance(result, Exception):
|
|
294
|
+
console.error(f"[red]Failed to disconnect {connector.url}:[/red] {result}")
|
|
295
|
+
console.hint("Check that the selected connector is currently connected.")
|
|
296
|
+
else:
|
|
297
|
+
successful_disconnections.append(connector)
|
|
298
|
+
|
|
299
|
+
# Wait for successful disconnections to complete
|
|
300
|
+
for connector in successful_disconnections:
|
|
301
|
+
console.success(f"[green]Successfully disconnected connector[/green] {connector.url}")
|