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.
@@ -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()
@@ -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
+ ]
@@ -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"]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agentplane = agentplane_sdk.cli:app