agentplane-sdk 0.0.1__py3-none-any.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.
- agentplane_sdk/__init__.py +43 -0
- agentplane_sdk/auth.py +99 -0
- agentplane_sdk/cli.py +326 -0
- agentplane_sdk/client.py +396 -0
- agentplane_sdk/config.py +78 -0
- agentplane_sdk/errors.py +54 -0
- agentplane_sdk-0.0.1.dist-info/METADATA +41 -0
- agentplane_sdk-0.0.1.dist-info/RECORD +10 -0
- agentplane_sdk-0.0.1.dist-info/WHEEL +4 -0
- agentplane_sdk-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""agentplane-sdk: thin typed client + CLI."""
|
|
2
|
+
|
|
3
|
+
from agentplane_sdk.auth import (
|
|
4
|
+
OidcClientCredentialsProvider,
|
|
5
|
+
StaticTokenProvider,
|
|
6
|
+
TokenProvider,
|
|
7
|
+
as_token_provider,
|
|
8
|
+
)
|
|
9
|
+
from agentplane_sdk.client import (
|
|
10
|
+
RegistryClient,
|
|
11
|
+
RuntimeClient,
|
|
12
|
+
SyncRegistryClient,
|
|
13
|
+
SyncRuntimeClient,
|
|
14
|
+
)
|
|
15
|
+
from agentplane_sdk.errors import (
|
|
16
|
+
AgentplaneError,
|
|
17
|
+
ApiError,
|
|
18
|
+
AuthError,
|
|
19
|
+
ConflictError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
TransportError,
|
|
22
|
+
ValidationFailedError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__version__ = "0.0.1"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"AgentplaneError",
|
|
29
|
+
"ApiError",
|
|
30
|
+
"AuthError",
|
|
31
|
+
"ConflictError",
|
|
32
|
+
"NotFoundError",
|
|
33
|
+
"OidcClientCredentialsProvider",
|
|
34
|
+
"RegistryClient",
|
|
35
|
+
"RuntimeClient",
|
|
36
|
+
"StaticTokenProvider",
|
|
37
|
+
"SyncRegistryClient",
|
|
38
|
+
"SyncRuntimeClient",
|
|
39
|
+
"TokenProvider",
|
|
40
|
+
"TransportError",
|
|
41
|
+
"ValidationFailedError",
|
|
42
|
+
"as_token_provider",
|
|
43
|
+
]
|
agentplane_sdk/auth.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Token providers: static bearer and OIDC client-credentials (SPEC §4.1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from agentplane_sdk.errors import AuthError, TransportError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class TokenProvider(Protocol):
|
|
15
|
+
"""Anything that can produce a bearer token."""
|
|
16
|
+
|
|
17
|
+
async def get_token(self) -> str: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StaticTokenProvider:
|
|
21
|
+
"""Wraps a fixed bearer token."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, token: str) -> None:
|
|
24
|
+
self._token = token
|
|
25
|
+
|
|
26
|
+
async def get_token(self) -> str:
|
|
27
|
+
return self._token
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OidcClientCredentialsProvider:
|
|
31
|
+
"""Fetches tokens via the OIDC client-credentials grant, cached until expiry."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
issuer: str,
|
|
36
|
+
client_id: str,
|
|
37
|
+
client_secret: str,
|
|
38
|
+
*,
|
|
39
|
+
audience: str | None = None,
|
|
40
|
+
leeway_s: float = 30.0,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._issuer = issuer.rstrip("/")
|
|
43
|
+
self._client_id = client_id
|
|
44
|
+
self._client_secret = client_secret
|
|
45
|
+
self._audience = audience
|
|
46
|
+
self._leeway_s = leeway_s
|
|
47
|
+
self._token: str | None = None
|
|
48
|
+
self._expires_at: float = 0.0
|
|
49
|
+
self._token_endpoint: str | None = None
|
|
50
|
+
|
|
51
|
+
async def get_token(self) -> str:
|
|
52
|
+
if self._token is not None and time.monotonic() < self._expires_at:
|
|
53
|
+
return self._token
|
|
54
|
+
try:
|
|
55
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
56
|
+
if self._token_endpoint is None:
|
|
57
|
+
discovery = await client.get(f"{self._issuer}/.well-known/openid-configuration")
|
|
58
|
+
discovery.raise_for_status()
|
|
59
|
+
endpoint = discovery.json().get("token_endpoint")
|
|
60
|
+
if not isinstance(endpoint, str):
|
|
61
|
+
raise AuthError("issuer discovery returned no token_endpoint")
|
|
62
|
+
self._token_endpoint = endpoint
|
|
63
|
+
data = {
|
|
64
|
+
"grant_type": "client_credentials",
|
|
65
|
+
"client_id": self._client_id,
|
|
66
|
+
"client_secret": self._client_secret,
|
|
67
|
+
}
|
|
68
|
+
if self._audience:
|
|
69
|
+
data["audience"] = self._audience
|
|
70
|
+
response = await client.post(self._token_endpoint, data=data)
|
|
71
|
+
except httpx.HTTPError as exc:
|
|
72
|
+
raise TransportError(f"token request failed: {exc}") from exc
|
|
73
|
+
if response.status_code != httpx.codes.OK:
|
|
74
|
+
raise AuthError(f"token endpoint returned {response.status_code}")
|
|
75
|
+
payload = response.json()
|
|
76
|
+
token = payload.get("access_token")
|
|
77
|
+
if not isinstance(token, str):
|
|
78
|
+
raise AuthError("token endpoint returned no access_token")
|
|
79
|
+
expires_in = payload.get("expires_in", 300)
|
|
80
|
+
ttl = float(expires_in) if isinstance(expires_in, int | float) else 300.0
|
|
81
|
+
self._token = token
|
|
82
|
+
self._expires_at = time.monotonic() + max(ttl - self._leeway_s, 5.0)
|
|
83
|
+
return token
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def as_token_provider(token: str | TokenProvider | None) -> TokenProvider | None:
|
|
87
|
+
if token is None:
|
|
88
|
+
return None
|
|
89
|
+
if isinstance(token, str):
|
|
90
|
+
return StaticTokenProvider(token)
|
|
91
|
+
return token
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = [
|
|
95
|
+
"OidcClientCredentialsProvider",
|
|
96
|
+
"StaticTokenProvider",
|
|
97
|
+
"TokenProvider",
|
|
98
|
+
"as_token_provider",
|
|
99
|
+
]
|
agentplane_sdk/cli.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""The ``agentplane`` CLI (SPEC §4.2).
|
|
2
|
+
|
|
3
|
+
Exit codes: 0 ok, 1 validation failed, 2 transport/auth error, 3 not found.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
from collections.abc import Coroutine
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Any
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
import yaml
|
|
16
|
+
from pydantic import TypeAdapter, ValidationError
|
|
17
|
+
|
|
18
|
+
from agentplane_core import (
|
|
19
|
+
FlowDefinition,
|
|
20
|
+
Resource,
|
|
21
|
+
ValidationResult,
|
|
22
|
+
validate_structure,
|
|
23
|
+
)
|
|
24
|
+
from agentplane_sdk.client import RegistryClient, RuntimeClient
|
|
25
|
+
from agentplane_sdk.config import resolve_config, token_provider_from_config
|
|
26
|
+
from agentplane_sdk.errors import (
|
|
27
|
+
AuthError,
|
|
28
|
+
ConflictError,
|
|
29
|
+
NotFoundError,
|
|
30
|
+
TransportError,
|
|
31
|
+
ValidationFailedError,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(name="agentplane", help="agentplane platform CLI", no_args_is_help=True)
|
|
35
|
+
resources_app = typer.Typer(help="Manage runtime resources", no_args_is_help=True)
|
|
36
|
+
app.add_typer(resources_app, name="resources")
|
|
37
|
+
|
|
38
|
+
EXIT_OK = 0
|
|
39
|
+
EXIT_VALIDATION = 1
|
|
40
|
+
EXIT_TRANSPORT = 2
|
|
41
|
+
EXIT_NOT_FOUND = 3
|
|
42
|
+
|
|
43
|
+
_RESOURCE_ADAPTER: TypeAdapter[Resource] = TypeAdapter(Resource)
|
|
44
|
+
|
|
45
|
+
RuntimeUrlOption = Annotated[
|
|
46
|
+
str | None, typer.Option("--runtime-url", envvar="AGENTPLANE_RUNTIME_URL")
|
|
47
|
+
]
|
|
48
|
+
RegistryUrlOption = Annotated[
|
|
49
|
+
str | None, typer.Option("--registry-url", envvar="AGENTPLANE_REGISTRY_URL")
|
|
50
|
+
]
|
|
51
|
+
TokenOption = Annotated[str | None, typer.Option("--token", envvar="AGENTPLANE_TOKEN")]
|
|
52
|
+
JsonFlag = Annotated[bool, typer.Option("--json", help="machine-readable output")]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fail(message: str, code: int) -> None:
|
|
56
|
+
typer.echo(message, err=True)
|
|
57
|
+
raise typer.Exit(code)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run[R](coro: Coroutine[Any, Any, R]) -> R:
|
|
61
|
+
try:
|
|
62
|
+
return asyncio.run(coro)
|
|
63
|
+
except ValidationFailedError as exc:
|
|
64
|
+
_print_issues(exc.result, as_json=False)
|
|
65
|
+
raise typer.Exit(EXIT_VALIDATION) from None
|
|
66
|
+
except NotFoundError as exc:
|
|
67
|
+
_fail(f"not found: {exc}", EXIT_NOT_FOUND)
|
|
68
|
+
except (TransportError, AuthError) as exc:
|
|
69
|
+
_fail(str(exc), EXIT_TRANSPORT)
|
|
70
|
+
except ConflictError as exc:
|
|
71
|
+
_fail(f"conflict: {exc}", EXIT_TRANSPORT)
|
|
72
|
+
raise AssertionError("unreachable")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_yaml(path: Path) -> dict[str, object]:
|
|
76
|
+
try:
|
|
77
|
+
with path.open(encoding="utf-8") as fh:
|
|
78
|
+
data = yaml.safe_load(fh)
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
_fail(f"file not found: {path}", EXIT_NOT_FOUND)
|
|
81
|
+
except yaml.YAMLError as exc:
|
|
82
|
+
_fail(f"invalid YAML in {path}: {exc}", EXIT_VALIDATION)
|
|
83
|
+
if not isinstance(data, dict):
|
|
84
|
+
_fail(f"{path} does not contain a mapping", EXIT_VALIDATION)
|
|
85
|
+
return {str(key): value for key, value in data.items()}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _print_issues(result: ValidationResult, *, as_json: bool) -> None:
|
|
89
|
+
if as_json:
|
|
90
|
+
typer.echo(result.model_dump_json(indent=2))
|
|
91
|
+
return
|
|
92
|
+
for issue in result.issues:
|
|
93
|
+
typer.echo(f"{issue.severity.upper()} {issue.code} {issue.path}: {issue.message}", err=True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _runtime_client(runtime_url: str | None, token: str | None) -> RuntimeClient:
|
|
97
|
+
config = resolve_config(runtime_url=runtime_url, token=token)
|
|
98
|
+
if not config.runtime_url:
|
|
99
|
+
_fail(
|
|
100
|
+
"no runtime URL configured (flag --runtime-url / env AGENTPLANE_RUNTIME_URL)",
|
|
101
|
+
EXIT_TRANSPORT,
|
|
102
|
+
)
|
|
103
|
+
assert config.runtime_url is not None
|
|
104
|
+
return RuntimeClient(config.runtime_url, token_provider_from_config(config))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _registry_client(registry_url: str | None, token: str | None) -> RegistryClient:
|
|
108
|
+
config = resolve_config(registry_url=registry_url, token=token)
|
|
109
|
+
if not config.registry_url:
|
|
110
|
+
_fail(
|
|
111
|
+
"no registry URL configured (flag --registry-url / env AGENTPLANE_REGISTRY_URL)",
|
|
112
|
+
EXIT_TRANSPORT,
|
|
113
|
+
)
|
|
114
|
+
assert config.registry_url is not None
|
|
115
|
+
return RegistryClient(config.registry_url, token_provider_from_config(config))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.command()
|
|
119
|
+
def validate(
|
|
120
|
+
file: Annotated[Path, typer.Argument(help="flow definition YAML")],
|
|
121
|
+
json_output: JsonFlag = False,
|
|
122
|
+
remote: Annotated[
|
|
123
|
+
bool, typer.Option("--remote", help="ask the runtime (authoritative, adds E020-E022)")
|
|
124
|
+
] = False,
|
|
125
|
+
runtime_url: RuntimeUrlOption = None,
|
|
126
|
+
token: TokenOption = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Validate a definition; local by default, --remote for the runtime's answer."""
|
|
129
|
+
raw = _load_yaml(file)
|
|
130
|
+
if remote:
|
|
131
|
+
|
|
132
|
+
async def remote_validate() -> ValidationResult:
|
|
133
|
+
async with _runtime_client(runtime_url, token) as client:
|
|
134
|
+
return await client.validate(raw)
|
|
135
|
+
|
|
136
|
+
result = _run(remote_validate())
|
|
137
|
+
else:
|
|
138
|
+
result = ValidationResult.from_issues(validate_structure(raw))
|
|
139
|
+
_print_issues(result, as_json=json_output)
|
|
140
|
+
raise typer.Exit(EXIT_OK if result.valid else EXIT_VALIDATION)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command()
|
|
144
|
+
def deploy(
|
|
145
|
+
file: Annotated[Path, typer.Argument(help="flow definition YAML")],
|
|
146
|
+
draft: Annotated[bool, typer.Option("--draft", help="create/update the draft only")] = False,
|
|
147
|
+
runtime_url: RuntimeUrlOption = None,
|
|
148
|
+
token: TokenOption = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Create-or-update the definition by name; deploys unless --draft."""
|
|
151
|
+
raw = _load_yaml(file)
|
|
152
|
+
try:
|
|
153
|
+
defn = FlowDefinition.model_validate(raw)
|
|
154
|
+
except ValidationError:
|
|
155
|
+
result = ValidationResult.from_issues(validate_structure(raw))
|
|
156
|
+
_print_issues(result, as_json=False)
|
|
157
|
+
raise typer.Exit(EXIT_VALIDATION) from None
|
|
158
|
+
|
|
159
|
+
async def do_deploy() -> str:
|
|
160
|
+
async with _runtime_client(runtime_url, token) as client:
|
|
161
|
+
try:
|
|
162
|
+
await client.create_draft(defn)
|
|
163
|
+
except ConflictError:
|
|
164
|
+
await client.update_draft(defn.name, defn)
|
|
165
|
+
if draft:
|
|
166
|
+
return f"draft saved: {defn.name}"
|
|
167
|
+
info = await client.deploy(defn.name)
|
|
168
|
+
return f"deployed {info.name} v{info.version} -> {info.endpoint_url}"
|
|
169
|
+
|
|
170
|
+
typer.echo(_run(do_deploy()))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.command()
|
|
174
|
+
def undeploy(
|
|
175
|
+
name: str,
|
|
176
|
+
runtime_url: RuntimeUrlOption = None,
|
|
177
|
+
token: TokenOption = None,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Stop serving a deployed definition and deregister it."""
|
|
180
|
+
|
|
181
|
+
async def do_undeploy() -> None:
|
|
182
|
+
async with _runtime_client(runtime_url, token) as client:
|
|
183
|
+
await client.undeploy(name)
|
|
184
|
+
|
|
185
|
+
_run(do_undeploy())
|
|
186
|
+
typer.echo(f"undeployed {name}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command(name="list")
|
|
190
|
+
def list_definitions(
|
|
191
|
+
status: Annotated[str | None, typer.Option("--status")] = None,
|
|
192
|
+
json_output: JsonFlag = False,
|
|
193
|
+
runtime_url: RuntimeUrlOption = None,
|
|
194
|
+
token: TokenOption = None,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""List definitions known to the runtime."""
|
|
197
|
+
|
|
198
|
+
async def do_list() -> list[dict[str, object]]:
|
|
199
|
+
async with _runtime_client(runtime_url, token) as client:
|
|
200
|
+
return [i.model_dump(mode="json") for i in await client.list(status)]
|
|
201
|
+
|
|
202
|
+
infos = _run(do_list())
|
|
203
|
+
if json_output:
|
|
204
|
+
typer.echo(json.dumps(infos, indent=2))
|
|
205
|
+
return
|
|
206
|
+
for info in infos:
|
|
207
|
+
endpoint = info.get("endpoint_url") or "-"
|
|
208
|
+
typer.echo(f"{info['name']:<30} {info['status']:<10} {endpoint}")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command()
|
|
212
|
+
def export(
|
|
213
|
+
name: str,
|
|
214
|
+
output: Annotated[Path | None, typer.Option("-o", "--output")] = None,
|
|
215
|
+
version: Annotated[int | None, typer.Option("--version")] = None,
|
|
216
|
+
runtime_url: RuntimeUrlOption = None,
|
|
217
|
+
token: TokenOption = None,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Export the canonical serialized definition."""
|
|
220
|
+
|
|
221
|
+
async def do_export() -> FlowDefinition:
|
|
222
|
+
async with _runtime_client(runtime_url, token) as client:
|
|
223
|
+
return await client.export(name, version)
|
|
224
|
+
|
|
225
|
+
defn = _run(do_export())
|
|
226
|
+
text = yaml.safe_dump(defn.canonical_dict(), sort_keys=False, allow_unicode=True)
|
|
227
|
+
if output is None:
|
|
228
|
+
typer.echo(text)
|
|
229
|
+
else:
|
|
230
|
+
output.write_text(text, encoding="utf-8")
|
|
231
|
+
typer.echo(f"wrote {output}")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@app.command()
|
|
235
|
+
def search(
|
|
236
|
+
query: Annotated[str, typer.Argument()] = "",
|
|
237
|
+
tags: Annotated[list[str] | None, typer.Option("--tags")] = None,
|
|
238
|
+
semantic: Annotated[bool, typer.Option("--semantic")] = False,
|
|
239
|
+
kind: Annotated[str | None, typer.Option("--kind")] = None,
|
|
240
|
+
json_output: JsonFlag = False,
|
|
241
|
+
registry_url: RegistryUrlOption = None,
|
|
242
|
+
token: TokenOption = None,
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Search the registry."""
|
|
245
|
+
|
|
246
|
+
async def do_search() -> list[dict[str, object]]:
|
|
247
|
+
async with _registry_client(registry_url, token) as client:
|
|
248
|
+
page = await client.search(query, tags=tags, kind=kind, semantic=semantic)
|
|
249
|
+
return [e.model_dump(mode="json") for e in page.items]
|
|
250
|
+
|
|
251
|
+
entries = _run(do_search())
|
|
252
|
+
if json_output:
|
|
253
|
+
typer.echo(json.dumps(entries, indent=2))
|
|
254
|
+
return
|
|
255
|
+
for entry in entries:
|
|
256
|
+
card = entry.get("card")
|
|
257
|
+
name = card.get("name", "?") if isinstance(card, dict) else "?"
|
|
258
|
+
typer.echo(f"{name:<30} {entry['kind']:<11} {entry['status']:<9} {entry['url']}")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@resources_app.command(name="list")
|
|
262
|
+
def resources_list(
|
|
263
|
+
kind: Annotated[str | None, typer.Option("--kind")] = None,
|
|
264
|
+
json_output: JsonFlag = False,
|
|
265
|
+
runtime_url: RuntimeUrlOption = None,
|
|
266
|
+
token: TokenOption = None,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""List resources."""
|
|
269
|
+
|
|
270
|
+
async def do_list() -> list[dict[str, object]]:
|
|
271
|
+
async with _runtime_client(runtime_url, token) as client:
|
|
272
|
+
resources = await client.list_resources(kind)
|
|
273
|
+
return [_RESOURCE_ADAPTER.dump_python(r, mode="json") for r in resources]
|
|
274
|
+
|
|
275
|
+
resources = _run(do_list())
|
|
276
|
+
if json_output:
|
|
277
|
+
typer.echo(json.dumps(resources, indent=2))
|
|
278
|
+
return
|
|
279
|
+
for resource in resources:
|
|
280
|
+
typer.echo(f"{resource['name']:<30} {resource['kind']}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@resources_app.command(name="create")
|
|
284
|
+
def resources_create(
|
|
285
|
+
file: Annotated[Path, typer.Option("-f", "--file", help="resource YAML")],
|
|
286
|
+
runtime_url: RuntimeUrlOption = None,
|
|
287
|
+
token: TokenOption = None,
|
|
288
|
+
) -> None:
|
|
289
|
+
"""Create a resource from a YAML file."""
|
|
290
|
+
raw = _load_yaml(file)
|
|
291
|
+
try:
|
|
292
|
+
resource = _RESOURCE_ADAPTER.validate_python(raw)
|
|
293
|
+
except ValidationError as exc:
|
|
294
|
+
_fail(f"invalid resource: {exc}", EXIT_VALIDATION)
|
|
295
|
+
raise AssertionError("unreachable") from None
|
|
296
|
+
|
|
297
|
+
async def do_create() -> str:
|
|
298
|
+
async with _runtime_client(runtime_url, token) as client:
|
|
299
|
+
created = await client.create_resource(resource)
|
|
300
|
+
return created.name
|
|
301
|
+
|
|
302
|
+
typer.echo(f"created resource {_run(do_create())}")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@resources_app.command(name="delete")
|
|
306
|
+
def resources_delete(
|
|
307
|
+
name: str,
|
|
308
|
+
runtime_url: RuntimeUrlOption = None,
|
|
309
|
+
token: TokenOption = None,
|
|
310
|
+
) -> None:
|
|
311
|
+
"""Delete a resource (refused while referenced)."""
|
|
312
|
+
|
|
313
|
+
async def do_delete() -> None:
|
|
314
|
+
async with _runtime_client(runtime_url, token) as client:
|
|
315
|
+
await client.delete_resource(name)
|
|
316
|
+
|
|
317
|
+
_run(do_delete())
|
|
318
|
+
typer.echo(f"deleted resource {name}")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def main() -> None: # pragma: no cover - thin wrapper
|
|
322
|
+
app()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
if __name__ == "__main__": # pragma: no cover
|
|
326
|
+
main()
|
agentplane_sdk/client.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Typed async clients for the registry and runtime APIs (SPEC §4.1).
|
|
2
|
+
|
|
3
|
+
Everything the SDK does is possible with plain HTTP — the SDK is
|
|
4
|
+
convenience, not a requirement.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from collections.abc import Callable, Coroutine, Mapping
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Any, Self, TypeVar
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from pydantic import TypeAdapter
|
|
17
|
+
|
|
18
|
+
from agentplane_core import (
|
|
19
|
+
Capabilities,
|
|
20
|
+
DefinitionInfo,
|
|
21
|
+
DeploymentInfo,
|
|
22
|
+
FlowDefinition,
|
|
23
|
+
Page,
|
|
24
|
+
RegistryEntry,
|
|
25
|
+
RegistryEntryCreate,
|
|
26
|
+
RegistryEntryPatch,
|
|
27
|
+
Resource,
|
|
28
|
+
ValidationResult,
|
|
29
|
+
)
|
|
30
|
+
from agentplane_sdk.auth import TokenProvider, as_token_provider
|
|
31
|
+
from agentplane_sdk.errors import (
|
|
32
|
+
ApiError,
|
|
33
|
+
AuthError,
|
|
34
|
+
ConflictError,
|
|
35
|
+
NotFoundError,
|
|
36
|
+
TransportError,
|
|
37
|
+
ValidationFailedError,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_RESOURCE_ADAPTER: TypeAdapter[Resource] = TypeAdapter(Resource)
|
|
41
|
+
_RESOURCE_LIST_ADAPTER: TypeAdapter[list[Resource]] = TypeAdapter(list[Resource])
|
|
42
|
+
_DEFINITION_LIST_ADAPTER: TypeAdapter[list[DefinitionInfo]] = TypeAdapter(list[DefinitionInfo])
|
|
43
|
+
|
|
44
|
+
T = TypeVar("T")
|
|
45
|
+
|
|
46
|
+
# `list` is also a client method name (SPEC API); alias the builtin for annotations.
|
|
47
|
+
_List = list
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _BaseClient:
|
|
51
|
+
"""Shared HTTP plumbing: base URL, bearer auth, error mapping."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
base_url: str,
|
|
56
|
+
token: str | TokenProvider | None = None,
|
|
57
|
+
*,
|
|
58
|
+
timeout: float = 30.0,
|
|
59
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._token_provider = as_token_provider(token)
|
|
62
|
+
self._client = httpx.AsyncClient(
|
|
63
|
+
base_url=base_url.rstrip("/"), timeout=timeout, transport=transport
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def __aenter__(self) -> Self:
|
|
67
|
+
return self
|
|
68
|
+
|
|
69
|
+
async def __aexit__(
|
|
70
|
+
self,
|
|
71
|
+
exc_type: type[BaseException] | None,
|
|
72
|
+
exc: BaseException | None,
|
|
73
|
+
tb: TracebackType | None,
|
|
74
|
+
) -> None:
|
|
75
|
+
await self.aclose()
|
|
76
|
+
|
|
77
|
+
async def aclose(self) -> None:
|
|
78
|
+
await self._client.aclose()
|
|
79
|
+
|
|
80
|
+
async def _request(
|
|
81
|
+
self,
|
|
82
|
+
method: str,
|
|
83
|
+
path: str,
|
|
84
|
+
*,
|
|
85
|
+
json: object | None = None,
|
|
86
|
+
params: Mapping[str, str | int | None] | None = None,
|
|
87
|
+
) -> httpx.Response:
|
|
88
|
+
headers: dict[str, str] = {}
|
|
89
|
+
if self._token_provider is not None:
|
|
90
|
+
headers["Authorization"] = f"Bearer {await self._token_provider.get_token()}"
|
|
91
|
+
try:
|
|
92
|
+
response = await self._client.request(
|
|
93
|
+
method,
|
|
94
|
+
path,
|
|
95
|
+
json=json,
|
|
96
|
+
params=self._clean_params(params),
|
|
97
|
+
headers=headers,
|
|
98
|
+
)
|
|
99
|
+
except httpx.HTTPError as exc:
|
|
100
|
+
raise TransportError(str(exc)) from exc
|
|
101
|
+
self._raise_for_status(response)
|
|
102
|
+
return response
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _clean_params(
|
|
106
|
+
params: Mapping[str, str | int | None] | None,
|
|
107
|
+
) -> dict[str, str | int] | None:
|
|
108
|
+
if params is None:
|
|
109
|
+
return None
|
|
110
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
114
|
+
status = response.status_code
|
|
115
|
+
if status < httpx.codes.BAD_REQUEST:
|
|
116
|
+
return
|
|
117
|
+
if status in (httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN):
|
|
118
|
+
raise AuthError(f"HTTP {status}: {response.text}")
|
|
119
|
+
if status == httpx.codes.NOT_FOUND:
|
|
120
|
+
raise NotFoundError(response.text)
|
|
121
|
+
if status == httpx.codes.CONFLICT:
|
|
122
|
+
raise ConflictError(response.text)
|
|
123
|
+
if status == httpx.codes.UNPROCESSABLE_ENTITY:
|
|
124
|
+
try:
|
|
125
|
+
result = ValidationResult.model_validate(response.json())
|
|
126
|
+
except ValueError:
|
|
127
|
+
raise ApiError(status, response.text) from None
|
|
128
|
+
raise ValidationFailedError(result)
|
|
129
|
+
raise ApiError(status, response.text)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class RegistryClient(_BaseClient):
|
|
133
|
+
"""Client for the agentplane-registry REST API (SPEC §5.1)."""
|
|
134
|
+
|
|
135
|
+
_prefix = "/api/v1"
|
|
136
|
+
|
|
137
|
+
async def register(self, entry: RegistryEntryCreate) -> RegistryEntry:
|
|
138
|
+
response = await self._request(
|
|
139
|
+
"POST", f"{self._prefix}/agents", json=entry.model_dump(mode="json")
|
|
140
|
+
)
|
|
141
|
+
return RegistryEntry.model_validate(response.json())
|
|
142
|
+
|
|
143
|
+
async def get(self, id: UUID) -> RegistryEntry:
|
|
144
|
+
response = await self._request("GET", f"{self._prefix}/agents/{id}")
|
|
145
|
+
return RegistryEntry.model_validate(response.json())
|
|
146
|
+
|
|
147
|
+
async def update(self, id: UUID, patch: RegistryEntryPatch) -> RegistryEntry:
|
|
148
|
+
response = await self._request(
|
|
149
|
+
"PUT",
|
|
150
|
+
f"{self._prefix}/agents/{id}",
|
|
151
|
+
json=patch.model_dump(mode="json", exclude_none=True),
|
|
152
|
+
)
|
|
153
|
+
return RegistryEntry.model_validate(response.json())
|
|
154
|
+
|
|
155
|
+
async def delete(self, id: UUID) -> None:
|
|
156
|
+
await self._request("DELETE", f"{self._prefix}/agents/{id}")
|
|
157
|
+
|
|
158
|
+
async def search(
|
|
159
|
+
self,
|
|
160
|
+
q: str = "",
|
|
161
|
+
tags: _List[str] | None = None,
|
|
162
|
+
kind: str | None = None,
|
|
163
|
+
status: str | None = None,
|
|
164
|
+
semantic: bool = False,
|
|
165
|
+
limit: int = 50,
|
|
166
|
+
offset: int = 0,
|
|
167
|
+
) -> Page:
|
|
168
|
+
params: dict[str, str | int | None] = {
|
|
169
|
+
"q": q,
|
|
170
|
+
"kind": kind,
|
|
171
|
+
"status": status,
|
|
172
|
+
"semantic": "true" if semantic else None,
|
|
173
|
+
"limit": limit,
|
|
174
|
+
"offset": offset,
|
|
175
|
+
}
|
|
176
|
+
if tags:
|
|
177
|
+
params["tags"] = ",".join(tags)
|
|
178
|
+
response = await self._request("GET", f"{self._prefix}/agents/search", params=params)
|
|
179
|
+
return Page.model_validate(response.json())
|
|
180
|
+
|
|
181
|
+
async def list(
|
|
182
|
+
self,
|
|
183
|
+
kind: str | None = None,
|
|
184
|
+
status: str | None = None,
|
|
185
|
+
tags: _List[str] | None = None,
|
|
186
|
+
owner: str | None = None,
|
|
187
|
+
limit: int = 50,
|
|
188
|
+
offset: int = 0,
|
|
189
|
+
) -> Page:
|
|
190
|
+
params: dict[str, str | int | None] = {
|
|
191
|
+
"kind": kind,
|
|
192
|
+
"status": status,
|
|
193
|
+
"owner": owner,
|
|
194
|
+
"limit": limit,
|
|
195
|
+
"offset": offset,
|
|
196
|
+
}
|
|
197
|
+
if tags:
|
|
198
|
+
params["tags"] = ",".join(tags)
|
|
199
|
+
response = await self._request("GET", f"{self._prefix}/agents", params=params)
|
|
200
|
+
return Page.model_validate(response.json())
|
|
201
|
+
|
|
202
|
+
async def capabilities(self) -> Capabilities:
|
|
203
|
+
response = await self._request("GET", f"{self._prefix}/capabilities")
|
|
204
|
+
return Capabilities.model_validate(response.json())
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class RuntimeClient(_BaseClient):
|
|
208
|
+
"""Client for the agentplane-runtime definitions/resources API (SPEC §6.1/§6.3)."""
|
|
209
|
+
|
|
210
|
+
_prefix = "/api/v1"
|
|
211
|
+
|
|
212
|
+
async def validate(self, defn: FlowDefinition | Mapping[str, object]) -> ValidationResult:
|
|
213
|
+
payload = defn.canonical_dict() if isinstance(defn, FlowDefinition) else dict(defn)
|
|
214
|
+
response = await self._request("POST", f"{self._prefix}/definitions/validate", json=payload)
|
|
215
|
+
return ValidationResult.model_validate(response.json())
|
|
216
|
+
|
|
217
|
+
async def create_draft(self, defn: FlowDefinition) -> DefinitionInfo:
|
|
218
|
+
response = await self._request(
|
|
219
|
+
"POST", f"{self._prefix}/definitions", json=defn.canonical_dict()
|
|
220
|
+
)
|
|
221
|
+
return DefinitionInfo.model_validate(response.json())
|
|
222
|
+
|
|
223
|
+
async def update_draft(self, name: str, defn: FlowDefinition) -> DefinitionInfo:
|
|
224
|
+
response = await self._request(
|
|
225
|
+
"PUT", f"{self._prefix}/definitions/{name}", json=defn.canonical_dict()
|
|
226
|
+
)
|
|
227
|
+
return DefinitionInfo.model_validate(response.json())
|
|
228
|
+
|
|
229
|
+
async def deploy(
|
|
230
|
+
self, name: str, *, version: int | None = None, ephemeral: bool = False
|
|
231
|
+
) -> DeploymentInfo:
|
|
232
|
+
params: dict[str, str | int | None] = {
|
|
233
|
+
"version": version,
|
|
234
|
+
"ephemeral": "true" if ephemeral else None,
|
|
235
|
+
}
|
|
236
|
+
response = await self._request(
|
|
237
|
+
"POST", f"{self._prefix}/definitions/{name}/deploy", params=params
|
|
238
|
+
)
|
|
239
|
+
return DeploymentInfo.model_validate(response.json())
|
|
240
|
+
|
|
241
|
+
async def undeploy(self, name: str) -> None:
|
|
242
|
+
await self._request("POST", f"{self._prefix}/definitions/{name}/undeploy")
|
|
243
|
+
|
|
244
|
+
async def get(self, name: str, *, include_definition: bool = False) -> DefinitionInfo:
|
|
245
|
+
params = {"include": "definition"} if include_definition else None
|
|
246
|
+
response = await self._request("GET", f"{self._prefix}/definitions/{name}", params=params)
|
|
247
|
+
return DefinitionInfo.model_validate(response.json())
|
|
248
|
+
|
|
249
|
+
async def list(self, status: str | None = None) -> _List[DefinitionInfo]:
|
|
250
|
+
response = await self._request(
|
|
251
|
+
"GET", f"{self._prefix}/definitions", params={"status": status}
|
|
252
|
+
)
|
|
253
|
+
return _DEFINITION_LIST_ADAPTER.validate_python(response.json())
|
|
254
|
+
|
|
255
|
+
async def export(self, name: str, version: int | None = None) -> FlowDefinition:
|
|
256
|
+
response = await self._request(
|
|
257
|
+
"GET", f"{self._prefix}/definitions/{name}/export", params={"version": version}
|
|
258
|
+
)
|
|
259
|
+
return FlowDefinition.model_validate(response.json())
|
|
260
|
+
|
|
261
|
+
async def delete(self, name: str) -> None:
|
|
262
|
+
await self._request("DELETE", f"{self._prefix}/definitions/{name}")
|
|
263
|
+
|
|
264
|
+
async def create_resource(self, resource: Resource) -> Resource:
|
|
265
|
+
response = await self._request(
|
|
266
|
+
"POST",
|
|
267
|
+
f"{self._prefix}/resources",
|
|
268
|
+
json=_RESOURCE_ADAPTER.dump_python(
|
|
269
|
+
resource, mode="json", context={"reveal_secrets": True}
|
|
270
|
+
),
|
|
271
|
+
)
|
|
272
|
+
return _RESOURCE_ADAPTER.validate_python(response.json())
|
|
273
|
+
|
|
274
|
+
async def list_resources(self, kind: str | None = None) -> _List[Resource]:
|
|
275
|
+
response = await self._request("GET", f"{self._prefix}/resources", params={"kind": kind})
|
|
276
|
+
return _RESOURCE_LIST_ADAPTER.validate_python(response.json())
|
|
277
|
+
|
|
278
|
+
async def delete_resource(self, name: str) -> None:
|
|
279
|
+
await self._request("DELETE", f"{self._prefix}/resources/{name}")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _run_sync[R](coro: Coroutine[Any, Any, R]) -> R:
|
|
283
|
+
return asyncio.run(coro)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class SyncRuntimeClient:
|
|
287
|
+
"""Blocking convenience wrapper generated via ``asyncio.run`` (SPEC §4.1)."""
|
|
288
|
+
|
|
289
|
+
def __init__(self, base_url: str, token: str | TokenProvider | None = None) -> None:
|
|
290
|
+
self._base_url = base_url
|
|
291
|
+
self._token = token
|
|
292
|
+
|
|
293
|
+
def _call(self, fn: Callable[[RuntimeClient], Coroutine[Any, Any, T]]) -> T:
|
|
294
|
+
async def runner() -> T:
|
|
295
|
+
async with RuntimeClient(self._base_url, self._token) as client:
|
|
296
|
+
return await fn(client)
|
|
297
|
+
|
|
298
|
+
return _run_sync(runner())
|
|
299
|
+
|
|
300
|
+
def validate(self, defn: FlowDefinition | Mapping[str, object]) -> ValidationResult:
|
|
301
|
+
return self._call(lambda c: c.validate(defn))
|
|
302
|
+
|
|
303
|
+
def create_draft(self, defn: FlowDefinition) -> DefinitionInfo:
|
|
304
|
+
return self._call(lambda c: c.create_draft(defn))
|
|
305
|
+
|
|
306
|
+
def update_draft(self, name: str, defn: FlowDefinition) -> DefinitionInfo:
|
|
307
|
+
return self._call(lambda c: c.update_draft(name, defn))
|
|
308
|
+
|
|
309
|
+
def deploy(
|
|
310
|
+
self, name: str, *, version: int | None = None, ephemeral: bool = False
|
|
311
|
+
) -> DeploymentInfo:
|
|
312
|
+
return self._call(lambda c: c.deploy(name, version=version, ephemeral=ephemeral))
|
|
313
|
+
|
|
314
|
+
def undeploy(self, name: str) -> None:
|
|
315
|
+
return self._call(lambda c: c.undeploy(name))
|
|
316
|
+
|
|
317
|
+
def get(self, name: str, *, include_definition: bool = False) -> DefinitionInfo:
|
|
318
|
+
return self._call(lambda c: c.get(name, include_definition=include_definition))
|
|
319
|
+
|
|
320
|
+
def list(self, status: str | None = None) -> _List[DefinitionInfo]:
|
|
321
|
+
return self._call(lambda c: c.list(status))
|
|
322
|
+
|
|
323
|
+
def export(self, name: str, version: int | None = None) -> FlowDefinition:
|
|
324
|
+
return self._call(lambda c: c.export(name, version))
|
|
325
|
+
|
|
326
|
+
def delete(self, name: str) -> None:
|
|
327
|
+
return self._call(lambda c: c.delete(name))
|
|
328
|
+
|
|
329
|
+
def create_resource(self, resource: Resource) -> Resource:
|
|
330
|
+
return self._call(lambda c: c.create_resource(resource))
|
|
331
|
+
|
|
332
|
+
def list_resources(self, kind: str | None = None) -> _List[Resource]:
|
|
333
|
+
return self._call(lambda c: c.list_resources(kind))
|
|
334
|
+
|
|
335
|
+
def delete_resource(self, name: str) -> None:
|
|
336
|
+
return self._call(lambda c: c.delete_resource(name))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class SyncRegistryClient:
|
|
340
|
+
"""Blocking convenience wrapper generated via ``asyncio.run`` (SPEC §4.1)."""
|
|
341
|
+
|
|
342
|
+
def __init__(self, base_url: str, token: str | TokenProvider | None = None) -> None:
|
|
343
|
+
self._base_url = base_url
|
|
344
|
+
self._token = token
|
|
345
|
+
|
|
346
|
+
def _call(self, fn: Callable[[RegistryClient], Coroutine[Any, Any, T]]) -> T:
|
|
347
|
+
async def runner() -> T:
|
|
348
|
+
async with RegistryClient(self._base_url, self._token) as client:
|
|
349
|
+
return await fn(client)
|
|
350
|
+
|
|
351
|
+
return _run_sync(runner())
|
|
352
|
+
|
|
353
|
+
def register(self, entry: RegistryEntryCreate) -> RegistryEntry:
|
|
354
|
+
return self._call(lambda c: c.register(entry))
|
|
355
|
+
|
|
356
|
+
def get(self, id: UUID) -> RegistryEntry:
|
|
357
|
+
return self._call(lambda c: c.get(id))
|
|
358
|
+
|
|
359
|
+
def update(self, id: UUID, patch: RegistryEntryPatch) -> RegistryEntry:
|
|
360
|
+
return self._call(lambda c: c.update(id, patch))
|
|
361
|
+
|
|
362
|
+
def delete(self, id: UUID) -> None:
|
|
363
|
+
return self._call(lambda c: c.delete(id))
|
|
364
|
+
|
|
365
|
+
def search(
|
|
366
|
+
self,
|
|
367
|
+
q: str = "",
|
|
368
|
+
tags: _List[str] | None = None,
|
|
369
|
+
kind: str | None = None,
|
|
370
|
+
status: str | None = None,
|
|
371
|
+
semantic: bool = False,
|
|
372
|
+
limit: int = 50,
|
|
373
|
+
offset: int = 0,
|
|
374
|
+
) -> Page:
|
|
375
|
+
return self._call(
|
|
376
|
+
lambda c: c.search(
|
|
377
|
+
q,
|
|
378
|
+
tags=tags,
|
|
379
|
+
kind=kind,
|
|
380
|
+
status=status,
|
|
381
|
+
semantic=semantic,
|
|
382
|
+
limit=limit,
|
|
383
|
+
offset=offset,
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def capabilities(self) -> Capabilities:
|
|
388
|
+
return self._call(lambda c: c.capabilities())
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
__all__ = [
|
|
392
|
+
"RegistryClient",
|
|
393
|
+
"RuntimeClient",
|
|
394
|
+
"SyncRegistryClient",
|
|
395
|
+
"SyncRuntimeClient",
|
|
396
|
+
]
|
agentplane_sdk/config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""CLI configuration resolution (SPEC §4.2).
|
|
2
|
+
|
|
3
|
+
Order: flags → env (``AGENTPLANE_*``) → ``~/.config/agentplane/config.toml``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import tomllib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict
|
|
13
|
+
|
|
14
|
+
from agentplane_sdk.auth import OidcClientCredentialsProvider, TokenProvider, as_token_provider
|
|
15
|
+
|
|
16
|
+
CONFIG_PATH = Path.home() / ".config" / "agentplane" / "config.toml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CliConfig(BaseModel):
|
|
20
|
+
model_config = ConfigDict(extra="ignore")
|
|
21
|
+
|
|
22
|
+
runtime_url: str | None = None
|
|
23
|
+
registry_url: str | None = None
|
|
24
|
+
token: str | None = None
|
|
25
|
+
oidc_issuer: str | None = None
|
|
26
|
+
oidc_client_id: str | None = None
|
|
27
|
+
oidc_client_secret: str | None = None
|
|
28
|
+
oidc_audience: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_config_file(path: Path) -> CliConfig:
|
|
32
|
+
if not path.is_file():
|
|
33
|
+
return CliConfig()
|
|
34
|
+
with path.open("rb") as fh:
|
|
35
|
+
return CliConfig.model_validate(tomllib.load(fh))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_config(
|
|
39
|
+
*,
|
|
40
|
+
runtime_url: str | None = None,
|
|
41
|
+
registry_url: str | None = None,
|
|
42
|
+
token: str | None = None,
|
|
43
|
+
config_path: Path | None = None,
|
|
44
|
+
) -> CliConfig:
|
|
45
|
+
"""Merge flags, environment and config file into one effective config."""
|
|
46
|
+
file_config = _load_config_file(config_path or CONFIG_PATH)
|
|
47
|
+
|
|
48
|
+
def pick(flag: str | None, env_name: str, file_value: str | None) -> str | None:
|
|
49
|
+
return flag or os.environ.get(env_name) or file_value
|
|
50
|
+
|
|
51
|
+
return CliConfig(
|
|
52
|
+
runtime_url=pick(runtime_url, "AGENTPLANE_RUNTIME_URL", file_config.runtime_url),
|
|
53
|
+
registry_url=pick(registry_url, "AGENTPLANE_REGISTRY_URL", file_config.registry_url),
|
|
54
|
+
token=pick(token, "AGENTPLANE_TOKEN", file_config.token),
|
|
55
|
+
oidc_issuer=pick(None, "AGENTPLANE_OIDC_ISSUER", file_config.oidc_issuer),
|
|
56
|
+
oidc_client_id=pick(None, "AGENTPLANE_OIDC_CLIENT_ID", file_config.oidc_client_id),
|
|
57
|
+
oidc_client_secret=pick(
|
|
58
|
+
None, "AGENTPLANE_OIDC_CLIENT_SECRET", file_config.oidc_client_secret
|
|
59
|
+
),
|
|
60
|
+
oidc_audience=pick(None, "AGENTPLANE_OIDC_AUDIENCE", file_config.oidc_audience),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def token_provider_from_config(config: CliConfig) -> TokenProvider | None:
|
|
65
|
+
"""Static token wins; otherwise OIDC client credentials when configured."""
|
|
66
|
+
if config.token:
|
|
67
|
+
return as_token_provider(config.token)
|
|
68
|
+
if config.oidc_issuer and config.oidc_client_id and config.oidc_client_secret:
|
|
69
|
+
return OidcClientCredentialsProvider(
|
|
70
|
+
config.oidc_issuer,
|
|
71
|
+
config.oidc_client_id,
|
|
72
|
+
config.oidc_client_secret,
|
|
73
|
+
audience=config.oidc_audience,
|
|
74
|
+
)
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = ["CONFIG_PATH", "CliConfig", "resolve_config", "token_provider_from_config"]
|
agentplane_sdk/errors.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""SDK error hierarchy mapped from HTTP responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agentplane_core import ValidationResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AgentplaneError(Exception):
|
|
9
|
+
"""Base class for all SDK errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TransportError(AgentplaneError):
|
|
13
|
+
"""Network-level failure (connect, timeout, DNS)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthError(AgentplaneError):
|
|
17
|
+
"""401/403 or token acquisition failure."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NotFoundError(AgentplaneError):
|
|
21
|
+
"""404 for a named object or entry."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConflictError(AgentplaneError):
|
|
25
|
+
"""409 — duplicate name or referenced object."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApiError(AgentplaneError):
|
|
29
|
+
"""Any other non-2xx response."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
32
|
+
super().__init__(f"HTTP {status_code}: {detail}")
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
self.detail = detail
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ValidationFailedError(AgentplaneError):
|
|
38
|
+
"""422 with a machine-readable ValidationResult."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, result: ValidationResult) -> None:
|
|
41
|
+
lines = ", ".join(f"{i.code}@{i.path}" for i in result.issues)
|
|
42
|
+
super().__init__(f"validation failed: {lines}")
|
|
43
|
+
self.result = result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"AgentplaneError",
|
|
48
|
+
"ApiError",
|
|
49
|
+
"AuthError",
|
|
50
|
+
"ConflictError",
|
|
51
|
+
"NotFoundError",
|
|
52
|
+
"TransportError",
|
|
53
|
+
"ValidationFailedError",
|
|
54
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentplane-sdk
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: agentplane typed client + CLI
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: agentplane-core<0.1.0,>=0.0.1
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
|
+
Requires-Dist: typer>=0.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# agentplane-sdk
|
|
14
|
+
|
|
15
|
+
Thin typed client + CLI for the agentplane platform. Everything the SDK does
|
|
16
|
+
is possible with plain HTTP — the SDK is convenience, not a requirement.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from agentplane_sdk import RuntimeClient
|
|
20
|
+
|
|
21
|
+
async with RuntimeClient("https://api.example/runtime", token="…") as client:
|
|
22
|
+
info = await client.deploy("support-rag")
|
|
23
|
+
print(info.endpoint_url)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
CLI:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
agentplane validate flow.yaml
|
|
30
|
+
agentplane deploy flow.yaml [--draft]
|
|
31
|
+
agentplane undeploy <name>
|
|
32
|
+
agentplane list [--status deployed] [--json]
|
|
33
|
+
agentplane export <name> [-o flow.yaml]
|
|
34
|
+
agentplane search "invoice" [--tags rag] [--semantic]
|
|
35
|
+
agentplane resources list|create -f res.yaml|delete <name>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Config resolution: flags → env (`AGENTPLANE_RUNTIME_URL`,
|
|
39
|
+
`AGENTPLANE_REGISTRY_URL`, `AGENTPLANE_TOKEN` or OIDC vars) →
|
|
40
|
+
`~/.config/agentplane/config.toml`. Exit codes: 0 ok, 1 validation failed,
|
|
41
|
+
2 transport/auth error, 3 not found.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
agentplane_sdk/__init__.py,sha256=Kz_feTTmj5mF8Y0It8gEn9kkFDzFRbgs0KBBM-UMH2g,874
|
|
2
|
+
agentplane_sdk/auth.py,sha256=K9jjuHv34FmCT-nH1R9DZHih1hAcpujv9iK-g-Cs2oQ,3337
|
|
3
|
+
agentplane_sdk/cli.py,sha256=aVb_XvLg-t6Dl-qq0Gwns1ngCSXiI3sVnjeTVflYlRk,10887
|
|
4
|
+
agentplane_sdk/client.py,sha256=VC2CQzHKdl7182VcKRy64hygtdXbTc3v4z9PfZwg8Q8,13901
|
|
5
|
+
agentplane_sdk/config.py,sha256=S_cMisfJY3R1zwaDilxSPOCug-2fybZJseeHFBIlpWg,2716
|
|
6
|
+
agentplane_sdk/errors.py,sha256=cHKdq5cjPYeGZ4xwKaBRpiDuTCq7ioZ30jdv-S23Nc0,1320
|
|
7
|
+
agentplane_sdk-0.0.1.dist-info/METADATA,sha256=jz-_cFj-x-DJSFuCZBj2_fHEASto6Beb_ST5QO_Hwh0,1239
|
|
8
|
+
agentplane_sdk-0.0.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
agentplane_sdk-0.0.1.dist-info/entry_points.txt,sha256=ZmcN_fr8ZAin63wlIe7g0zhKZeV-vUf82XJsNMq8wMM,54
|
|
10
|
+
agentplane_sdk-0.0.1.dist-info/RECORD,,
|