agentplane-sdk 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ # internal working documents — not part of the public repo
2
+ CLAUDE.md
3
+ SPEC.md
4
+ .claude/
5
+
6
+ # python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.egg-info/
10
+ .venv/
11
+ dist/
12
+ build/
13
+
14
+ # tooling caches
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ .ruff_cache/
18
+ .coverage
19
+ htmlcov/
20
+
21
+ # local databases and state
22
+ *.db
23
+ registry.db
24
+ runtime.db
25
+
26
+ # environments / secrets
27
+ .env
28
+ .env.*
29
+
30
+ # editors / OS
31
+ .vscode/
32
+ .idea/
33
+ .DS_Store
34
+ Thumbs.db
@@ -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,29 @@
1
+ # agentplane-sdk
2
+
3
+ Thin typed client + CLI for the agentplane platform. Everything the SDK does
4
+ is possible with plain HTTP — the SDK is convenience, not a requirement.
5
+
6
+ ```python
7
+ from agentplane_sdk import RuntimeClient
8
+
9
+ async with RuntimeClient("https://api.example/runtime", token="…") as client:
10
+ info = await client.deploy("support-rag")
11
+ print(info.endpoint_url)
12
+ ```
13
+
14
+ CLI:
15
+
16
+ ```
17
+ agentplane validate flow.yaml
18
+ agentplane deploy flow.yaml [--draft]
19
+ agentplane undeploy <name>
20
+ agentplane list [--status deployed] [--json]
21
+ agentplane export <name> [-o flow.yaml]
22
+ agentplane search "invoice" [--tags rag] [--semantic]
23
+ agentplane resources list|create -f res.yaml|delete <name>
24
+ ```
25
+
26
+ Config resolution: flags → env (`AGENTPLANE_RUNTIME_URL`,
27
+ `AGENTPLANE_REGISTRY_URL`, `AGENTPLANE_TOKEN` or OIDC vars) →
28
+ `~/.config/agentplane/config.toml`. Exit codes: 0 ok, 1 validation failed,
29
+ 2 transport/auth error, 3 not found.
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "agentplane-sdk"
3
+ version = "0.0.1"
4
+ description = "agentplane typed client + CLI"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.12"
8
+ dependencies = [
9
+ "agentplane-core>=0.0.1,<0.1.0",
10
+ "httpx>=0.27",
11
+ "typer>=0.12",
12
+ "pyyaml>=6.0",
13
+ ]
14
+
15
+ [project.scripts]
16
+ agentplane = "agentplane_sdk.cli:app"
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/agentplane_sdk"]
24
+
25
+ [tool.uv.sources]
26
+ agentplane-core = { workspace = true }
@@ -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
+ ]
@@ -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
+ ]
@@ -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()