daimon-sdk 0.1.0__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,35 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build-and-publish:
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ contents: read
14
+ id-token: write
15
+
16
+ steps:
17
+ - name: Check out repository
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Set up Python
21
+ uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.12"
24
+
25
+ - name: Install package and test dependencies
26
+ run: python -m pip install --upgrade pip && python -m pip install -e ".[dev]" build
27
+
28
+ - name: Run unit tests
29
+ run: python -m pytest tests/test_unit.py -q
30
+
31
+ - name: Build distributions
32
+ run: python -m build
33
+
34
+ - name: Publish to PyPI
35
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ .pytest_cache/
2
+ .venv/
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ __pycache__/
7
+ .pytest-*
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: daimon-sdk
3
+ Version: 0.1.0
4
+ Summary: Typed async Python SDK for daimon MCP services.
5
+ Author: processd contributors
6
+ License: MIT
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: fastmcp<4,>=3.1.1
9
+ Requires-Dist: httpx<1,>=0.28
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest-asyncio<1,>=0.24; extra == 'dev'
12
+ Requires-Dist: pytest<9,>=8.3; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # daimon-sdk
16
+
17
+ Typed async Python SDK for `processd-mcp`.
18
+
19
+ `daimon-sdk` wraps the raw MCP tool surface exposed by `processd-mcp` and presents it as grouped Python APIs such as `client.files.read()` and `client.exec.start_session()`. The SDK keeps `processd-standalone` as the contract source of truth and focuses on:
20
+
21
+ - connection and token wiring
22
+ - typed request/response handling
23
+ - structured tool error mapping
24
+ - interactive session helpers
25
+ - compatibility tests against a real `processd-mcp` binary
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install daimon-sdk
31
+ ```
32
+
33
+ For local development:
34
+
35
+ ```bash
36
+ pip install -e ".[dev]"
37
+ ```
38
+
39
+ ## Quickstart
40
+
41
+ ```python
42
+ import asyncio
43
+
44
+ from daimon_sdk import DaimonClient
45
+
46
+
47
+ async def main() -> None:
48
+ async with DaimonClient("http://127.0.0.1:8080/mcp") as client:
49
+ runtime = await client.runtime.get_context()
50
+ print(runtime.base_workdir)
51
+
52
+ result = await client.files.glob("**/*.rs", path=runtime.base_workdir)
53
+ print(result.filenames[:5])
54
+
55
+ bash = await client.exec.bash("printf 'hello from processd\\n'")
56
+ print(bash.stdout)
57
+
58
+
59
+ asyncio.run(main())
60
+ ```
61
+
62
+ ## Raw MCP vs SDK
63
+
64
+ Raw MCP:
65
+
66
+ ```python
67
+ payload = await mcp_client.call_tool("Read", {"file_path": "/tmp/demo.txt"})
68
+ ```
69
+
70
+ SDK:
71
+
72
+ ```python
73
+ read = await client.files.read("/tmp/demo.txt")
74
+ print(read.file.content)
75
+ ```
76
+
77
+ ## API Overview
78
+
79
+ - `DaimonClient(base_url, access_token=None, timeout_s=30.0)`
80
+ - `await client.connect()` / `await client.close()`
81
+ - `async with DaimonClient(...) as client`
82
+ - `client.runtime.get_context()`
83
+ - `client.files.read() / write() / edit() / glob() / grep()`
84
+ - `client.exec.bash() / start_session()`
85
+ - `SessionHandle.write() / poll() / wait_for_exit() / close()`
86
+ - `client.web.fetch()`
87
+ - `client.raw.call_tool()`
88
+
89
+ ## Local Testing
90
+
91
+ The SDK compatibility tests expect a sibling checkout of `processd-standalone`:
92
+
93
+ ```text
94
+ e2b-project/
95
+ processd-standalone/
96
+ processd-sdk/
97
+ ```
98
+
99
+ Run tests with an environment that already has the dev dependencies installed:
100
+
101
+ ```bash
102
+ PYTHONPATH=src python -m pytest -q
103
+ ```
104
+
105
+ The E2E suite builds and launches `../processd-standalone/target/debug/processd-mcp`.
106
+
107
+ ## Release
108
+
109
+ Releases are published from GitHub Actions when a tag matching `v*` is pushed.
110
+
111
+ ```bash
112
+ git tag v0.1.0
113
+ git push origin v0.1.0
114
+ ```
115
+
116
+ The tag version must match `pyproject.toml`'s project version.
@@ -0,0 +1,102 @@
1
+ # daimon-sdk
2
+
3
+ Typed async Python SDK for `processd-mcp`.
4
+
5
+ `daimon-sdk` wraps the raw MCP tool surface exposed by `processd-mcp` and presents it as grouped Python APIs such as `client.files.read()` and `client.exec.start_session()`. The SDK keeps `processd-standalone` as the contract source of truth and focuses on:
6
+
7
+ - connection and token wiring
8
+ - typed request/response handling
9
+ - structured tool error mapping
10
+ - interactive session helpers
11
+ - compatibility tests against a real `processd-mcp` binary
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install daimon-sdk
17
+ ```
18
+
19
+ For local development:
20
+
21
+ ```bash
22
+ pip install -e ".[dev]"
23
+ ```
24
+
25
+ ## Quickstart
26
+
27
+ ```python
28
+ import asyncio
29
+
30
+ from daimon_sdk import DaimonClient
31
+
32
+
33
+ async def main() -> None:
34
+ async with DaimonClient("http://127.0.0.1:8080/mcp") as client:
35
+ runtime = await client.runtime.get_context()
36
+ print(runtime.base_workdir)
37
+
38
+ result = await client.files.glob("**/*.rs", path=runtime.base_workdir)
39
+ print(result.filenames[:5])
40
+
41
+ bash = await client.exec.bash("printf 'hello from processd\\n'")
42
+ print(bash.stdout)
43
+
44
+
45
+ asyncio.run(main())
46
+ ```
47
+
48
+ ## Raw MCP vs SDK
49
+
50
+ Raw MCP:
51
+
52
+ ```python
53
+ payload = await mcp_client.call_tool("Read", {"file_path": "/tmp/demo.txt"})
54
+ ```
55
+
56
+ SDK:
57
+
58
+ ```python
59
+ read = await client.files.read("/tmp/demo.txt")
60
+ print(read.file.content)
61
+ ```
62
+
63
+ ## API Overview
64
+
65
+ - `DaimonClient(base_url, access_token=None, timeout_s=30.0)`
66
+ - `await client.connect()` / `await client.close()`
67
+ - `async with DaimonClient(...) as client`
68
+ - `client.runtime.get_context()`
69
+ - `client.files.read() / write() / edit() / glob() / grep()`
70
+ - `client.exec.bash() / start_session()`
71
+ - `SessionHandle.write() / poll() / wait_for_exit() / close()`
72
+ - `client.web.fetch()`
73
+ - `client.raw.call_tool()`
74
+
75
+ ## Local Testing
76
+
77
+ The SDK compatibility tests expect a sibling checkout of `processd-standalone`:
78
+
79
+ ```text
80
+ e2b-project/
81
+ processd-standalone/
82
+ processd-sdk/
83
+ ```
84
+
85
+ Run tests with an environment that already has the dev dependencies installed:
86
+
87
+ ```bash
88
+ PYTHONPATH=src python -m pytest -q
89
+ ```
90
+
91
+ The E2E suite builds and launches `../processd-standalone/target/debug/processd-mcp`.
92
+
93
+ ## Release
94
+
95
+ Releases are published from GitHub Actions when a tag matching `v*` is pushed.
96
+
97
+ ```bash
98
+ git tag v0.1.0
99
+ git push origin v0.1.0
100
+ ```
101
+
102
+ The tag version must match `pyproject.toml`'s project version.
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+
6
+ from daimon_sdk import DaimonClient
7
+
8
+
9
+ async def main() -> None:
10
+ token = os.environ["PROCESSD_TOKEN"]
11
+ async with DaimonClient("http://127.0.0.1:8080/mcp", access_token=token) as client:
12
+ result = await client.exec.exec_command("echo secure", yield_time_ms=200)
13
+ print(result.output.strip())
14
+
15
+
16
+ asyncio.run(main())
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from daimon_sdk import DaimonClient
6
+
7
+
8
+ async def main() -> None:
9
+ async with DaimonClient("http://127.0.0.1:8080/mcp") as client:
10
+ session = await client.exec.start_session("/bin/cat", tty=True, yield_time_ms=100)
11
+ echoed = await session.write("hello session\n", yield_time_ms=100)
12
+ print(echoed.output)
13
+ exited = await session.close(exit_payload="\u0004", yield_time_ms=500)
14
+ print(exited.exit_code)
15
+
16
+
17
+ asyncio.run(main())
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ from daimon_sdk import DaimonClient
7
+
8
+
9
+ async def main() -> None:
10
+ target = Path("/tmp/daimon-sdk-demo.txt")
11
+ async with DaimonClient("http://127.0.0.1:8080/mcp") as client:
12
+ await client.files.edit(str(target), old_string="", new_string="hello from sdk\n")
13
+ read = await client.files.read(str(target))
14
+ print(read.file.content)
15
+ glob = await client.files.glob("*.txt", path=str(target.parent))
16
+ print(glob.filenames)
17
+
18
+
19
+ asyncio.run(main())
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from daimon_sdk import DaimonClient
6
+
7
+
8
+ async def main() -> None:
9
+ async with DaimonClient("http://127.0.0.1:8080/mcp") as client:
10
+ runtime = await client.runtime.get_context()
11
+ print(runtime.summary)
12
+
13
+ page = await client.web.fetch("https://example.com")
14
+ print(page.status_code, page.result_type)
15
+
16
+
17
+ asyncio.run(main())
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "daimon-sdk"
7
+ version = "0.1.0"
8
+ description = "Typed async Python SDK for daimon MCP services."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "processd contributors" },
14
+ ]
15
+ dependencies = [
16
+ "fastmcp>=3.1.1,<4",
17
+ "httpx>=0.28,<1",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "pytest>=8.3,<9",
23
+ "pytest-asyncio>=0.24,<1",
24
+ ]
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/daimon_sdk"]
28
+
29
+ [tool.pytest.ini_options]
30
+ asyncio_mode = "auto"
31
+ testpaths = ["tests"]
32
+ python_files = ["test_*.py"]
@@ -0,0 +1,35 @@
1
+ from .client import DaimonClient
2
+ from .exceptions import (
3
+ DaimonConnectionError,
4
+ DaimonError,
5
+ DaimonProtocolError,
6
+ DaimonToolError,
7
+ )
8
+ from .models import (
9
+ BashResult,
10
+ EditResult,
11
+ ExecResult,
12
+ GlobResult,
13
+ GrepResult,
14
+ RuntimeContextResult,
15
+ SessionHandle,
16
+ WebFetchResult,
17
+ WriteResult,
18
+ )
19
+
20
+ __all__ = [
21
+ "BashResult",
22
+ "EditResult",
23
+ "ExecResult",
24
+ "GlobResult",
25
+ "GrepResult",
26
+ "DaimonClient",
27
+ "DaimonConnectionError",
28
+ "DaimonError",
29
+ "DaimonProtocolError",
30
+ "DaimonToolError",
31
+ "RuntimeContextResult",
32
+ "SessionHandle",
33
+ "WebFetchResult",
34
+ "WriteResult",
35
+ ]
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from fastmcp import Client
9
+ from fastmcp.client.transports import StreamableHttpTransport
10
+
11
+ from .exceptions import DaimonConnectionError, DaimonProtocolError, DaimonToolError
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class ToolCallEnvelope:
16
+ tool_name: str
17
+ payload: dict[str, Any]
18
+ content_blocks: list[dict[str, Any]]
19
+ raw_result: Any
20
+
21
+
22
+ def _content_block_to_dict(block: Any) -> dict[str, Any]:
23
+ if isinstance(block, dict):
24
+ return dict(block)
25
+ data: dict[str, Any] = {}
26
+ for key in ("type", "text", "data", "mimeType", "mime_type", "annotations"):
27
+ value = getattr(block, key, None)
28
+ if value is not None:
29
+ data[key] = value
30
+ if not data and hasattr(block, "model_dump"):
31
+ dumped = block.model_dump()
32
+ if isinstance(dumped, dict):
33
+ data = dumped
34
+ if not data and hasattr(block, "__dict__"):
35
+ data = {
36
+ key: value
37
+ for key, value in vars(block).items()
38
+ if not key.startswith("_")
39
+ }
40
+ return data
41
+
42
+
43
+ def decode_tool_result(result: Any) -> tuple[dict[str, Any], list[dict[str, Any]]]:
44
+ if isinstance(getattr(result, "structured_content", None), dict):
45
+ payload = dict(result.structured_content)
46
+ content = [_content_block_to_dict(block) for block in getattr(result, "content", []) or []]
47
+ return payload, content
48
+ if getattr(result, "data", None) is not None:
49
+ data = result.data
50
+ if isinstance(data, dict):
51
+ payload = dict(data)
52
+ elif isinstance(data, str):
53
+ try:
54
+ payload = json.loads(data)
55
+ except json.JSONDecodeError as exc:
56
+ raise DaimonProtocolError(f"tool response data was not valid JSON: {data}") from exc
57
+ else:
58
+ raise DaimonProtocolError(f"unsupported tool response data type: {type(data)!r}")
59
+ content = [_content_block_to_dict(block) for block in getattr(result, "content", []) or []]
60
+ return payload, content
61
+ content = getattr(result, "content", None) or []
62
+ if content:
63
+ text = getattr(content[0], "text", None)
64
+ if isinstance(text, str):
65
+ try:
66
+ payload = json.loads(text)
67
+ except json.JSONDecodeError as exc:
68
+ raise DaimonProtocolError(f"tool response text was not valid JSON: {text}") from exc
69
+ return payload, [_content_block_to_dict(block) for block in content]
70
+ raise DaimonProtocolError(f"unable to decode tool result: {result!r}")
71
+
72
+
73
+ class FastMCPTransportAdapter:
74
+ def __init__(self, base_url: str, *, access_token: str | None, timeout_s: float) -> None:
75
+ self.base_url = base_url
76
+ self.access_token = access_token
77
+ self.timeout_s = timeout_s
78
+ self._client: Client | None = None
79
+
80
+ @property
81
+ def client(self) -> Client:
82
+ if self._client is None:
83
+ raise DaimonConnectionError("client is not connected")
84
+ return self._client
85
+
86
+ async def connect(self) -> None:
87
+ if self._client is not None:
88
+ return
89
+ headers = {"X-Access-Token": self.access_token} if self.access_token else None
90
+ transport = StreamableHttpTransport(
91
+ self.base_url,
92
+ headers=headers,
93
+ httpx_client_factory=self._httpx_client_factory,
94
+ )
95
+ client = Client(transport, timeout=self.timeout_s)
96
+ try:
97
+ await client.__aenter__()
98
+ except Exception as exc: # pragma: no cover - fastmcp exception types vary
99
+ raise DaimonConnectionError(str(exc)) from exc
100
+ self._client = client
101
+
102
+ async def close(self) -> None:
103
+ if self._client is None:
104
+ return
105
+ try:
106
+ await self._client.__aexit__(None, None, None)
107
+ finally:
108
+ self._client = None
109
+
110
+ async def call_tool(
111
+ self,
112
+ tool_name: str,
113
+ arguments: dict[str, Any],
114
+ *,
115
+ raise_on_error: bool = True,
116
+ ) -> ToolCallEnvelope:
117
+ await self.connect()
118
+ try:
119
+ result = await self.client.call_tool(tool_name, arguments, raise_on_error=False)
120
+ except Exception as exc: # pragma: no cover - transport-side failures vary
121
+ raise DaimonConnectionError(str(exc)) from exc
122
+ payload, content_blocks = decode_tool_result(result)
123
+ if raise_on_error and isinstance(payload.get("error"), str):
124
+ raise DaimonToolError(payload["error"], tool_name=tool_name, payload=payload)
125
+ return ToolCallEnvelope(
126
+ tool_name=tool_name,
127
+ payload=payload,
128
+ content_blocks=content_blocks,
129
+ raw_result=result,
130
+ )
131
+
132
+ def _httpx_client_factory(self, **kwargs: Any) -> httpx.AsyncClient:
133
+ headers = dict(kwargs.pop("headers", {}) or {})
134
+ kwargs.setdefault("timeout", self.timeout_s)
135
+ kwargs.setdefault("follow_redirects", True)
136
+ return httpx.AsyncClient(headers=headers, **kwargs)