personalclaw-client 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.
- personalclaw_client-0.1.0/PKG-INFO +70 -0
- personalclaw_client-0.1.0/README.md +40 -0
- personalclaw_client-0.1.0/personalclaw_client/__init__.py +15 -0
- personalclaw_client-0.1.0/personalclaw_client/client.py +483 -0
- personalclaw_client-0.1.0/personalclaw_client/errors.py +64 -0
- personalclaw_client-0.1.0/personalclaw_client.egg-info/PKG-INFO +70 -0
- personalclaw_client-0.1.0/personalclaw_client.egg-info/SOURCES.txt +11 -0
- personalclaw_client-0.1.0/personalclaw_client.egg-info/dependency_links.txt +1 -0
- personalclaw_client-0.1.0/personalclaw_client.egg-info/requires.txt +7 -0
- personalclaw_client-0.1.0/personalclaw_client.egg-info/top_level.txt +1 -0
- personalclaw_client-0.1.0/pyproject.toml +54 -0
- personalclaw_client-0.1.0/setup.cfg +4 -0
- personalclaw_client-0.1.0/tests/test_client.py +124 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: personalclaw-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PersonalClaw Client — async Python client for the PersonalClaw Gateway
|
|
5
|
+
Author: PersonalClaw contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://personalclaw.dev
|
|
8
|
+
Project-URL: Documentation, https://github.com/PersonalClaw/PersonalClaw/tree/main/docs
|
|
9
|
+
Project-URL: Source, https://github.com/PersonalClaw/PersonalClaw
|
|
10
|
+
Project-URL: Issues, https://github.com/PersonalClaw/PersonalClaw/issues
|
|
11
|
+
Keywords: personalclaw,client,ai,agent,gateway
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Framework :: AsyncIO
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: aiohttp>=3.9
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
28
|
+
Requires-Dist: hypothesis>=6.0; extra == "dev"
|
|
29
|
+
Requires-Dist: aiohttp; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# personalclaw-client
|
|
32
|
+
|
|
33
|
+
An async Python client for the [PersonalClaw](https://personalclaw.dev) Gateway.
|
|
34
|
+
|
|
35
|
+
`personalclaw-client` is the small, dependency-light library that apps and
|
|
36
|
+
external tools use to talk to a running PersonalClaw gateway — send messages,
|
|
37
|
+
read status, resolve an app's data directory — without vendoring the core
|
|
38
|
+
package. It is versioned and published independently of the core gateway so a
|
|
39
|
+
tool can pin a stable client contract.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pip install personalclaw-client
|
|
45
|
+
# or
|
|
46
|
+
uv pip install personalclaw-client
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from personalclaw_client import PersonalClawClient
|
|
53
|
+
|
|
54
|
+
async with PersonalClawClient(app_name="my-app") as pc:
|
|
55
|
+
ok = await pc.ping()
|
|
56
|
+
status = await pc.get_status()
|
|
57
|
+
await pc.send_message("session-1", "hello")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Errors
|
|
61
|
+
|
|
62
|
+
Client calls raise `PersonalClawError` (with a machine-readable `ErrorCode`) on
|
|
63
|
+
gateway/transport failures — catch it to distinguish "gateway unreachable" from
|
|
64
|
+
"bad request".
|
|
65
|
+
|
|
66
|
+
## Links
|
|
67
|
+
|
|
68
|
+
- Homepage: https://personalclaw.dev
|
|
69
|
+
- Source & issues: https://github.com/PersonalClaw/PersonalClaw
|
|
70
|
+
- License: MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# personalclaw-client
|
|
2
|
+
|
|
3
|
+
An async Python client for the [PersonalClaw](https://personalclaw.dev) Gateway.
|
|
4
|
+
|
|
5
|
+
`personalclaw-client` is the small, dependency-light library that apps and
|
|
6
|
+
external tools use to talk to a running PersonalClaw gateway — send messages,
|
|
7
|
+
read status, resolve an app's data directory — without vendoring the core
|
|
8
|
+
package. It is versioned and published independently of the core gateway so a
|
|
9
|
+
tool can pin a stable client contract.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pip install personalclaw-client
|
|
15
|
+
# or
|
|
16
|
+
uv pip install personalclaw-client
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from personalclaw_client import PersonalClawClient
|
|
23
|
+
|
|
24
|
+
async with PersonalClawClient(app_name="my-app") as pc:
|
|
25
|
+
ok = await pc.ping()
|
|
26
|
+
status = await pc.get_status()
|
|
27
|
+
await pc.send_message("session-1", "hello")
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Errors
|
|
31
|
+
|
|
32
|
+
Client calls raise `PersonalClawError` (with a machine-readable `ErrorCode`) on
|
|
33
|
+
gateway/transport failures — catch it to distinguish "gateway unreachable" from
|
|
34
|
+
"bad request".
|
|
35
|
+
|
|
36
|
+
## Links
|
|
37
|
+
|
|
38
|
+
- Homepage: https://personalclaw.dev
|
|
39
|
+
- Source & issues: https://github.com/PersonalClaw/PersonalClaw
|
|
40
|
+
- License: MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""personalclaw-client — async Python client for the PersonalClaw Gateway.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from personalclaw_client import PersonalClawClient
|
|
6
|
+
|
|
7
|
+
async with PersonalClawClient(app_name="my-app") as mc:
|
|
8
|
+
ok = await mc.ping()
|
|
9
|
+
status = await mc.get_status()
|
|
10
|
+
await mc.send_message("session-1", "hello")
|
|
11
|
+
"""
|
|
12
|
+
from personalclaw_client.client import PersonalClawClient
|
|
13
|
+
from personalclaw_client.errors import PersonalClawError, ErrorCode
|
|
14
|
+
|
|
15
|
+
__all__ = ["PersonalClawClient", "PersonalClawError", "ErrorCode"]
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""PersonalClawClient — async Python client for the PersonalClaw Gateway.
|
|
2
|
+
|
|
3
|
+
Standalone package (no dependency on backend main package).
|
|
4
|
+
Uses aiohttp for HTTP communication.
|
|
5
|
+
|
|
6
|
+
Mirrors the TypeScript @personalclaw/client API surface.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable, Awaitable
|
|
19
|
+
|
|
20
|
+
import aiohttp
|
|
21
|
+
|
|
22
|
+
from personalclaw_client.errors import PersonalClawError, ErrorCode, http_error
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Single source of truth for this client's default port. Mirrors the gateway's
|
|
27
|
+
# config.loader._DEFAULT_PORT (this package ships standalone and can't import it).
|
|
28
|
+
_DEFAULT_PORT = 10000
|
|
29
|
+
_DEFAULT_BASE_URL = f"http://localhost:{_DEFAULT_PORT}"
|
|
30
|
+
_DEFAULT_TIMEOUT = 30
|
|
31
|
+
_DEFAULT_MAX_RETRIES = 3
|
|
32
|
+
_DEFAULT_RETRY_BASE_DELAY = 1.0 # seconds
|
|
33
|
+
_MAX_BACKOFF = 30.0
|
|
34
|
+
_DEFAULT_MESSAGE_LIMIT = 40_000
|
|
35
|
+
_CONTEXT_BUFFER_LIMIT = 50
|
|
36
|
+
|
|
37
|
+
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_loopback(url: str) -> bool:
|
|
41
|
+
try:
|
|
42
|
+
from urllib.parse import urlparse
|
|
43
|
+
return urlparse(url).hostname in _LOOPBACK_HOSTS
|
|
44
|
+
except Exception:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _compute_backoff(attempt: int, base_delay: float) -> float:
|
|
49
|
+
return min(base_delay * (2 ** attempt), _MAX_BACKOFF)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_app_secret(app_name: str) -> str:
|
|
53
|
+
"""Read the per-app secret from disk. Returns empty string if unavailable."""
|
|
54
|
+
home = os.environ.get("PERSONALCLAW_HOME", str(Path.home() / ".personalclaw"))
|
|
55
|
+
secret_path = Path(home) / "apps" / app_name / ".app_secret"
|
|
56
|
+
try:
|
|
57
|
+
return secret_path.read_text(encoding="utf-8").strip()
|
|
58
|
+
except (OSError, FileNotFoundError):
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _exchange_app_token(base_url: str, app_name: str, secret: str) -> str:
|
|
63
|
+
"""Exchange an app secret for an app-scoped token via the Gateway."""
|
|
64
|
+
async with aiohttp.ClientSession() as session:
|
|
65
|
+
async with session.post(
|
|
66
|
+
f"{base_url}/api/apps/{app_name}/token",
|
|
67
|
+
headers={"X-App-Secret": secret, "Content-Type": "application/json"},
|
|
68
|
+
) as resp:
|
|
69
|
+
if not resp.ok:
|
|
70
|
+
raise PersonalClawError(
|
|
71
|
+
ErrorCode.AUTH_EXPIRED,
|
|
72
|
+
f"App token exchange failed: HTTP {resp.status}",
|
|
73
|
+
status=resp.status,
|
|
74
|
+
)
|
|
75
|
+
data = await resp.json()
|
|
76
|
+
return str(data.get("token", ""))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class ContextEntry:
|
|
81
|
+
content: str
|
|
82
|
+
source: str | None = None
|
|
83
|
+
ephemeral: bool = True
|
|
84
|
+
max_age: float | None = None # seconds
|
|
85
|
+
injected_at: float = field(default_factory=time.time)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PersonalClawClient:
|
|
89
|
+
"""Async HTTP client for the PersonalClaw Gateway.
|
|
90
|
+
|
|
91
|
+
Usage::
|
|
92
|
+
|
|
93
|
+
async with PersonalClawClient(app_name="my-app") as mc:
|
|
94
|
+
ok = await mc.ping()
|
|
95
|
+
slots = await mc.list_sessions()
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
base_url: str = "",
|
|
102
|
+
token: str = "",
|
|
103
|
+
app_name: str = "",
|
|
104
|
+
timeout: int = _DEFAULT_TIMEOUT,
|
|
105
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
106
|
+
retry_base_delay: float = _DEFAULT_RETRY_BASE_DELAY,
|
|
107
|
+
message_length_limit: int = _DEFAULT_MESSAGE_LIMIT,
|
|
108
|
+
on_auth_expired: Callable[[], Awaitable[str]] | None = None,
|
|
109
|
+
):
|
|
110
|
+
port = os.environ.get("PERSONALCLAW_PORT", str(_DEFAULT_PORT))
|
|
111
|
+
self.base_url = (base_url or f"http://localhost:{port}").rstrip("/")
|
|
112
|
+
self.token = token
|
|
113
|
+
self.app_name = app_name
|
|
114
|
+
self.timeout = timeout
|
|
115
|
+
self.max_retries = max_retries
|
|
116
|
+
self.retry_base_delay = retry_base_delay
|
|
117
|
+
self.message_length_limit = message_length_limit
|
|
118
|
+
self._session: aiohttp.ClientSession | None = None
|
|
119
|
+
self._pending_buffer: list[ContextEntry] = []
|
|
120
|
+
self._default_session: str | None = None
|
|
121
|
+
|
|
122
|
+
# Auto-token: if app_name is set and no explicit auth, read secret
|
|
123
|
+
self._app_secret = ""
|
|
124
|
+
has_explicit_auth = bool(token or on_auth_expired)
|
|
125
|
+
if app_name and not has_explicit_auth:
|
|
126
|
+
self._app_secret = _read_app_secret(app_name)
|
|
127
|
+
|
|
128
|
+
if self._app_secret and not on_auth_expired:
|
|
129
|
+
self._on_auth_expired = self._auto_refresh_token
|
|
130
|
+
else:
|
|
131
|
+
self._on_auth_expired = on_auth_expired
|
|
132
|
+
|
|
133
|
+
async def _auto_refresh_token(self) -> str:
|
|
134
|
+
"""Exchange the on-disk app secret for a fresh token."""
|
|
135
|
+
return await _exchange_app_token(self.base_url, self.app_name, self._app_secret)
|
|
136
|
+
|
|
137
|
+
async def authenticate(self) -> bool:
|
|
138
|
+
"""Bootstrap app auth by exchanging the on-disk secret for a token.
|
|
139
|
+
|
|
140
|
+
Call once after construction if no explicit token was provided.
|
|
141
|
+
No-op if the client already has a token or no app secret is available.
|
|
142
|
+
"""
|
|
143
|
+
if self.token or not self._app_secret or not self.app_name:
|
|
144
|
+
return True
|
|
145
|
+
try:
|
|
146
|
+
self.token = await _exchange_app_token(
|
|
147
|
+
self.base_url, self.app_name, self._app_secret
|
|
148
|
+
)
|
|
149
|
+
return True
|
|
150
|
+
except Exception:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
async def __aenter__(self) -> PersonalClawClient:
|
|
154
|
+
self._session = aiohttp.ClientSession(
|
|
155
|
+
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
|
156
|
+
)
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
160
|
+
await self.close()
|
|
161
|
+
|
|
162
|
+
async def close(self) -> None:
|
|
163
|
+
if self._session:
|
|
164
|
+
await self._session.close()
|
|
165
|
+
self._session = None
|
|
166
|
+
|
|
167
|
+
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
168
|
+
if not self._session:
|
|
169
|
+
self._session = aiohttp.ClientSession(
|
|
170
|
+
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
|
171
|
+
)
|
|
172
|
+
return self._session
|
|
173
|
+
|
|
174
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
175
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
176
|
+
if self.token:
|
|
177
|
+
headers["Cookie"] = f"pc_token={self.token}"
|
|
178
|
+
return headers
|
|
179
|
+
|
|
180
|
+
def _check_auth(self) -> None:
|
|
181
|
+
if not self.token and not _is_loopback(self.base_url):
|
|
182
|
+
raise PersonalClawError(
|
|
183
|
+
ErrorCode.AUTH_REQUIRED,
|
|
184
|
+
"Auth token required for remote Gateway connections",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def _request(
|
|
188
|
+
self,
|
|
189
|
+
method: str,
|
|
190
|
+
path: str,
|
|
191
|
+
body: Any = None,
|
|
192
|
+
) -> Any:
|
|
193
|
+
"""Core request with retry logic."""
|
|
194
|
+
self._check_auth()
|
|
195
|
+
session = self._ensure_session()
|
|
196
|
+
url = f"{self.base_url}{path}"
|
|
197
|
+
last_error: PersonalClawError | None = None
|
|
198
|
+
|
|
199
|
+
for attempt in range(self.max_retries + 1):
|
|
200
|
+
try:
|
|
201
|
+
kwargs: dict[str, Any] = {"headers": self._auth_headers()}
|
|
202
|
+
if body is not None:
|
|
203
|
+
kwargs["json"] = body
|
|
204
|
+
|
|
205
|
+
async with session.request(method, url, **kwargs) as resp:
|
|
206
|
+
# Auth expired — try refresh once
|
|
207
|
+
if resp.status in (401, 403) and self._on_auth_expired and attempt == 0:
|
|
208
|
+
try:
|
|
209
|
+
self.token = await self._on_auth_expired()
|
|
210
|
+
continue
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
if resp.ok:
|
|
215
|
+
ct = resp.headers.get("content-type", "")
|
|
216
|
+
if "application/json" in ct:
|
|
217
|
+
return await resp.json()
|
|
218
|
+
return {}
|
|
219
|
+
|
|
220
|
+
# Non-retryable 4xx (except 429)
|
|
221
|
+
if 400 <= resp.status < 500 and resp.status != 429:
|
|
222
|
+
text = await resp.text()
|
|
223
|
+
raise http_error(resp.status, text or None)
|
|
224
|
+
|
|
225
|
+
# Retryable
|
|
226
|
+
text = await resp.text()
|
|
227
|
+
last_error = http_error(resp.status, text or None)
|
|
228
|
+
|
|
229
|
+
if attempt < self.max_retries:
|
|
230
|
+
if resp.status == 429:
|
|
231
|
+
retry_after = resp.headers.get("Retry-After")
|
|
232
|
+
try:
|
|
233
|
+
delay = float(retry_after) if retry_after else _compute_backoff(attempt, self.retry_base_delay)
|
|
234
|
+
except (ValueError, TypeError):
|
|
235
|
+
delay = _compute_backoff(attempt, self.retry_base_delay)
|
|
236
|
+
else:
|
|
237
|
+
delay = _compute_backoff(attempt, self.retry_base_delay)
|
|
238
|
+
await asyncio.sleep(delay)
|
|
239
|
+
|
|
240
|
+
except PersonalClawError as e:
|
|
241
|
+
# Non-retryable errors (4xx except 429) were raised directly
|
|
242
|
+
# above — re-raise them immediately. Retryable errors (5xx,
|
|
243
|
+
# 429) are stored in last_error and fall through to the
|
|
244
|
+
# backoff sleep, so they never reach this branch.
|
|
245
|
+
raise
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
last_error = PersonalClawError(
|
|
248
|
+
ErrorCode.NETWORK_ERROR,
|
|
249
|
+
str(exc),
|
|
250
|
+
)
|
|
251
|
+
if attempt < self.max_retries:
|
|
252
|
+
await asyncio.sleep(_compute_backoff(attempt, self.retry_base_delay))
|
|
253
|
+
|
|
254
|
+
raise last_error or PersonalClawError(ErrorCode.NETWORK_ERROR, "Request failed")
|
|
255
|
+
|
|
256
|
+
async def _get(self, path: str) -> Any:
|
|
257
|
+
return await self._request("GET", path)
|
|
258
|
+
|
|
259
|
+
async def _post(self, path: str, body: Any = None) -> Any:
|
|
260
|
+
return await self._request("POST", path, body)
|
|
261
|
+
|
|
262
|
+
async def _put(self, path: str, body: Any = None) -> Any:
|
|
263
|
+
return await self._request("PUT", path, body)
|
|
264
|
+
|
|
265
|
+
async def _delete(self, path: str) -> Any:
|
|
266
|
+
return await self._request("DELETE", path)
|
|
267
|
+
|
|
268
|
+
# ── Connection ──
|
|
269
|
+
|
|
270
|
+
async def ping(self) -> bool:
|
|
271
|
+
try:
|
|
272
|
+
await self._get("/api/status")
|
|
273
|
+
return True
|
|
274
|
+
except Exception:
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
async def get_status(self) -> dict[str, Any]:
|
|
278
|
+
return await self._get("/api/status")
|
|
279
|
+
|
|
280
|
+
async def get_system_info(self) -> dict[str, Any]:
|
|
281
|
+
return await self._get("/api/system")
|
|
282
|
+
|
|
283
|
+
# ── Sessions ──
|
|
284
|
+
|
|
285
|
+
async def create_session(self, name: str, agent: str = "") -> dict[str, Any]:
|
|
286
|
+
body: dict[str, str] = {"name": name}
|
|
287
|
+
if agent:
|
|
288
|
+
body["agent"] = agent
|
|
289
|
+
return await self._post("/api/chat/sessions", body)
|
|
290
|
+
|
|
291
|
+
async def list_sessions(self) -> list[dict[str, Any]]:
|
|
292
|
+
result = await self._get("/api/chat/sessions")
|
|
293
|
+
return result if isinstance(result, list) else []
|
|
294
|
+
|
|
295
|
+
async def delete_session(self, session_id: str) -> None:
|
|
296
|
+
await self._delete(f"/api/chat/sessions/{session_id}")
|
|
297
|
+
|
|
298
|
+
async def send_message(self, session_id: str, message: str) -> None:
|
|
299
|
+
if len(message) > self.message_length_limit:
|
|
300
|
+
raise PersonalClawError(
|
|
301
|
+
ErrorCode.VALIDATION_ERROR,
|
|
302
|
+
f"Message length {len(message)} exceeds limit {self.message_length_limit}",
|
|
303
|
+
)
|
|
304
|
+
if self._default_session and session_id == self._default_session and self._pending_buffer:
|
|
305
|
+
await self.flush_pending_context(session_id)
|
|
306
|
+
await self._post("/api/chat", {"message": message, "session": session_id})
|
|
307
|
+
|
|
308
|
+
# ── Subagents ──
|
|
309
|
+
|
|
310
|
+
async def spawn(self, task: str, agent: str = "") -> str:
|
|
311
|
+
body: dict[str, str] = {"task": task}
|
|
312
|
+
if agent:
|
|
313
|
+
body["agent"] = agent
|
|
314
|
+
result = await self._post("/api/spawn", body)
|
|
315
|
+
return str(result.get("id", ""))
|
|
316
|
+
|
|
317
|
+
async def spawn_many(self, tasks: list[str], agents: list[str] | None = None) -> list[str]:
|
|
318
|
+
|
|
319
|
+
coros = [
|
|
320
|
+
self.spawn(task, agents[i] if agents and i < len(agents) else "")
|
|
321
|
+
for i, task in enumerate(tasks)
|
|
322
|
+
]
|
|
323
|
+
return list(await asyncio.gather(*coros))
|
|
324
|
+
|
|
325
|
+
async def list_subagents(self) -> list[dict[str, Any]]:
|
|
326
|
+
result = await self._get("/api/spawn")
|
|
327
|
+
return result if isinstance(result, list) else []
|
|
328
|
+
|
|
329
|
+
async def get_subagent_status(self, agent_id: str) -> dict[str, Any]:
|
|
330
|
+
return await self._get(f"/api/spawn/{agent_id}")
|
|
331
|
+
|
|
332
|
+
# ── Cron ──
|
|
333
|
+
|
|
334
|
+
async def add_cron(self, name: str, **options: Any) -> dict[str, Any]:
|
|
335
|
+
return await self._post("/api/crons", {"name": name, **options})
|
|
336
|
+
|
|
337
|
+
async def list_crons(self) -> list[dict[str, Any]]:
|
|
338
|
+
result = await self._get("/api/crons")
|
|
339
|
+
return result if isinstance(result, list) else []
|
|
340
|
+
|
|
341
|
+
async def update_cron(self, job_id: str, **options: Any) -> dict[str, Any]:
|
|
342
|
+
return await self._put(f"/api/crons/{job_id}", options)
|
|
343
|
+
|
|
344
|
+
async def remove_cron(self, job_id: str) -> None:
|
|
345
|
+
await self._delete(f"/api/crons/{job_id}")
|
|
346
|
+
|
|
347
|
+
async def pause_cron(self, job_id: str) -> None:
|
|
348
|
+
await self._post(f"/api/crons/{job_id}/enable", {"enabled": False})
|
|
349
|
+
|
|
350
|
+
async def resume_cron(self, job_id: str) -> None:
|
|
351
|
+
await self._post(f"/api/crons/{job_id}/enable", {"enabled": True})
|
|
352
|
+
|
|
353
|
+
# ── Lessons ──
|
|
354
|
+
|
|
355
|
+
async def add_lesson(self, rule: str, category: str, scope: str = "") -> None:
|
|
356
|
+
await self._post("/api/lessons", {"rule": rule, "category": category, "scope": scope})
|
|
357
|
+
|
|
358
|
+
async def list_lessons(self) -> list[dict[str, Any]]:
|
|
359
|
+
result = await self._get("/api/lessons")
|
|
360
|
+
return result if isinstance(result, list) else []
|
|
361
|
+
|
|
362
|
+
async def remove_lesson(self, query: str) -> None:
|
|
363
|
+
await self._delete_with_body("/api/lessons", {"rule": query})
|
|
364
|
+
|
|
365
|
+
async def _delete_with_body(self, path: str, body: Any) -> Any:
|
|
366
|
+
return await self._request("DELETE", path, body)
|
|
367
|
+
|
|
368
|
+
# ── Messages ──
|
|
369
|
+
|
|
370
|
+
async def send_notification(self, text: str, **options: Any) -> None:
|
|
371
|
+
if len(text) > self.message_length_limit:
|
|
372
|
+
raise PersonalClawError(
|
|
373
|
+
ErrorCode.VALIDATION_ERROR,
|
|
374
|
+
f"Message length {len(text)} exceeds limit {self.message_length_limit}",
|
|
375
|
+
)
|
|
376
|
+
await self._post("/api/send-message", {"text": text, **options})
|
|
377
|
+
|
|
378
|
+
# ── MCP Servers ──
|
|
379
|
+
|
|
380
|
+
async def list_mcp_servers(self) -> list[dict[str, Any]]:
|
|
381
|
+
result = await self._get("/api/mcp/servers")
|
|
382
|
+
return result if isinstance(result, list) else []
|
|
383
|
+
|
|
384
|
+
async def register_mcp_server(
|
|
385
|
+
self, name: str, command: str, args: list[str] | None = None, env: dict[str, str] | None = None,
|
|
386
|
+
) -> None:
|
|
387
|
+
if not name or not command:
|
|
388
|
+
raise PersonalClawError(ErrorCode.VALIDATION_ERROR, "MCP server requires name and command")
|
|
389
|
+
body: dict[str, Any] = {"command": command}
|
|
390
|
+
if args:
|
|
391
|
+
body["args"] = args
|
|
392
|
+
if env:
|
|
393
|
+
body["env"] = env
|
|
394
|
+
await self._put(f"/api/mcp/servers/{name}", body)
|
|
395
|
+
|
|
396
|
+
async def remove_mcp_server(self, name: str) -> None:
|
|
397
|
+
await self._delete(f"/api/mcp/servers/{name}")
|
|
398
|
+
|
|
399
|
+
# ── Agent Runtime ──
|
|
400
|
+
|
|
401
|
+
async def dispatch_agent(self, agent: str, prompt: str) -> dict[str, Any]:
|
|
402
|
+
return await self._post("/api/chat", {"message": prompt, "agent": agent, "app": self.app_name})
|
|
403
|
+
|
|
404
|
+
async def dispatch_agent_async(self, agent: str, prompt: str) -> str:
|
|
405
|
+
result = await self._post("/api/spawn", {"task": prompt, "agent": agent})
|
|
406
|
+
return str(result.get("id", ""))
|
|
407
|
+
|
|
408
|
+
async def get_task_result(self, task_id: str) -> dict[str, Any]:
|
|
409
|
+
return await self._get(f"/api/spawn/{task_id}")
|
|
410
|
+
|
|
411
|
+
# ── App Storage ──
|
|
412
|
+
|
|
413
|
+
def get_app_data_dir(self) -> Path:
|
|
414
|
+
home = os.environ.get("PERSONALCLAW_HOME", str(Path.home() / ".personalclaw"))
|
|
415
|
+
return Path(home) / "apps" / (self.app_name or "unknown") / "data"
|
|
416
|
+
|
|
417
|
+
async def get_app_config(self) -> dict[str, Any]:
|
|
418
|
+
return await self._get(f"/api/apps/{self.app_name}/config")
|
|
419
|
+
|
|
420
|
+
async def set_app_config(self, config: dict[str, Any]) -> None:
|
|
421
|
+
await self._put(f"/api/apps/{self.app_name}/config", config)
|
|
422
|
+
|
|
423
|
+
# ── Memory ──
|
|
424
|
+
|
|
425
|
+
async def memory_search(self, query: str, top_k: int = 8) -> list[dict[str, Any]]:
|
|
426
|
+
from urllib.parse import quote
|
|
427
|
+
result = await self._get(f"/api/memory/episodic/search?q={quote(query)}&top_k={top_k}")
|
|
428
|
+
return result if isinstance(result, list) else []
|
|
429
|
+
|
|
430
|
+
# ── Context Injection ──
|
|
431
|
+
|
|
432
|
+
async def inject_context(
|
|
433
|
+
self,
|
|
434
|
+
session_id: str | None,
|
|
435
|
+
content: str,
|
|
436
|
+
*,
|
|
437
|
+
source: str | None = None,
|
|
438
|
+
ephemeral: bool = True,
|
|
439
|
+
max_age: float | None = None,
|
|
440
|
+
) -> None:
|
|
441
|
+
entry = ContextEntry(
|
|
442
|
+
content=content, source=source, ephemeral=ephemeral,
|
|
443
|
+
max_age=max_age, injected_at=time.time(),
|
|
444
|
+
)
|
|
445
|
+
if session_id is None:
|
|
446
|
+
self._pending_buffer.append(entry)
|
|
447
|
+
if len(self._pending_buffer) > _CONTEXT_BUFFER_LIMIT:
|
|
448
|
+
self._pending_buffer.pop(0)
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
await self._post(f"/api/chat/sessions/{session_id}/context", {
|
|
452
|
+
"content": content, "source": source,
|
|
453
|
+
"ephemeral": ephemeral, "maxAge": max_age,
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
async def flush_pending_context(self, session_id: str) -> None:
|
|
457
|
+
now = time.time()
|
|
458
|
+
to_flush = [
|
|
459
|
+
e for e in self._pending_buffer
|
|
460
|
+
if e.max_age is None or e.injected_at + e.max_age >= now
|
|
461
|
+
]
|
|
462
|
+
self._pending_buffer.clear()
|
|
463
|
+
failed: list[Any] = []
|
|
464
|
+
last_exc: Exception | None = None
|
|
465
|
+
for entry in to_flush:
|
|
466
|
+
try:
|
|
467
|
+
await self._post(f"/api/chat/sessions/{session_id}/context", {
|
|
468
|
+
"content": entry.content, "source": entry.source,
|
|
469
|
+
"ephemeral": entry.ephemeral, "maxAge": entry.max_age,
|
|
470
|
+
})
|
|
471
|
+
except Exception as exc:
|
|
472
|
+
last_exc = exc
|
|
473
|
+
failed.append(entry)
|
|
474
|
+
self._pending_buffer.extend(failed)
|
|
475
|
+
if last_exc:
|
|
476
|
+
raise last_exc
|
|
477
|
+
|
|
478
|
+
def set_default_session(self, session_id: str) -> None:
|
|
479
|
+
self._default_session = session_id
|
|
480
|
+
|
|
481
|
+
@property
|
|
482
|
+
def pending_context_count(self) -> int:
|
|
483
|
+
return len(self._pending_buffer)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Structured error types for personalclaw-client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ErrorCode(str, Enum):
|
|
9
|
+
AUTH_REQUIRED = "AUTH_REQUIRED"
|
|
10
|
+
AUTH_EXPIRED = "AUTH_EXPIRED"
|
|
11
|
+
VALIDATION_ERROR = "VALIDATION_ERROR"
|
|
12
|
+
NOT_FOUND = "NOT_FOUND"
|
|
13
|
+
RATE_LIMITED = "RATE_LIMITED"
|
|
14
|
+
SERVER_ERROR = "SERVER_ERROR"
|
|
15
|
+
NETWORK_ERROR = "NETWORK_ERROR"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PersonalClawError(Exception):
|
|
19
|
+
"""Structured error with code, message, status, and optional body."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
code: ErrorCode | str,
|
|
24
|
+
message: str,
|
|
25
|
+
status: int | None = None,
|
|
26
|
+
body: Any = None,
|
|
27
|
+
):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.code = ErrorCode(code) if isinstance(code, str) else code
|
|
30
|
+
self.status = status
|
|
31
|
+
self.body = body
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict[str, Any]:
|
|
34
|
+
d: dict[str, Any] = {"code": self.code.value, "message": str(self)}
|
|
35
|
+
if self.status is not None:
|
|
36
|
+
d["status"] = self.status
|
|
37
|
+
if self.body is not None:
|
|
38
|
+
d["body"] = self.body
|
|
39
|
+
return d
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def http_status_to_code(status: int) -> ErrorCode:
|
|
43
|
+
if status == 401:
|
|
44
|
+
return ErrorCode.AUTH_EXPIRED
|
|
45
|
+
if status == 403:
|
|
46
|
+
return ErrorCode.AUTH_EXPIRED
|
|
47
|
+
if status == 404:
|
|
48
|
+
return ErrorCode.NOT_FOUND
|
|
49
|
+
if status == 429:
|
|
50
|
+
return ErrorCode.RATE_LIMITED
|
|
51
|
+
if status >= 500:
|
|
52
|
+
return ErrorCode.SERVER_ERROR
|
|
53
|
+
return ErrorCode.VALIDATION_ERROR
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def http_error(status: int, body: Any = None) -> PersonalClawError:
|
|
57
|
+
code = http_status_to_code(status)
|
|
58
|
+
if isinstance(body, str):
|
|
59
|
+
message = body
|
|
60
|
+
elif isinstance(body, dict) and "error" in body:
|
|
61
|
+
message = str(body["error"])
|
|
62
|
+
else:
|
|
63
|
+
message = f"HTTP {status}"
|
|
64
|
+
return PersonalClawError(code, message, status=status, body=body)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: personalclaw-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PersonalClaw Client — async Python client for the PersonalClaw Gateway
|
|
5
|
+
Author: PersonalClaw contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://personalclaw.dev
|
|
8
|
+
Project-URL: Documentation, https://github.com/PersonalClaw/PersonalClaw/tree/main/docs
|
|
9
|
+
Project-URL: Source, https://github.com/PersonalClaw/PersonalClaw
|
|
10
|
+
Project-URL: Issues, https://github.com/PersonalClaw/PersonalClaw/issues
|
|
11
|
+
Keywords: personalclaw,client,ai,agent,gateway
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Framework :: AsyncIO
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: aiohttp>=3.9
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
28
|
+
Requires-Dist: hypothesis>=6.0; extra == "dev"
|
|
29
|
+
Requires-Dist: aiohttp; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# personalclaw-client
|
|
32
|
+
|
|
33
|
+
An async Python client for the [PersonalClaw](https://personalclaw.dev) Gateway.
|
|
34
|
+
|
|
35
|
+
`personalclaw-client` is the small, dependency-light library that apps and
|
|
36
|
+
external tools use to talk to a running PersonalClaw gateway — send messages,
|
|
37
|
+
read status, resolve an app's data directory — without vendoring the core
|
|
38
|
+
package. It is versioned and published independently of the core gateway so a
|
|
39
|
+
tool can pin a stable client contract.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pip install personalclaw-client
|
|
45
|
+
# or
|
|
46
|
+
uv pip install personalclaw-client
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from personalclaw_client import PersonalClawClient
|
|
53
|
+
|
|
54
|
+
async with PersonalClawClient(app_name="my-app") as pc:
|
|
55
|
+
ok = await pc.ping()
|
|
56
|
+
status = await pc.get_status()
|
|
57
|
+
await pc.send_message("session-1", "hello")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Errors
|
|
61
|
+
|
|
62
|
+
Client calls raise `PersonalClawError` (with a machine-readable `ErrorCode`) on
|
|
63
|
+
gateway/transport failures — catch it to distinguish "gateway unreachable" from
|
|
64
|
+
"bad request".
|
|
65
|
+
|
|
66
|
+
## Links
|
|
67
|
+
|
|
68
|
+
- Homepage: https://personalclaw.dev
|
|
69
|
+
- Source & issues: https://github.com/PersonalClaw/PersonalClaw
|
|
70
|
+
- License: MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
personalclaw_client/__init__.py
|
|
4
|
+
personalclaw_client/client.py
|
|
5
|
+
personalclaw_client/errors.py
|
|
6
|
+
personalclaw_client.egg-info/PKG-INFO
|
|
7
|
+
personalclaw_client.egg-info/SOURCES.txt
|
|
8
|
+
personalclaw_client.egg-info/dependency_links.txt
|
|
9
|
+
personalclaw_client.egg-info/requires.txt
|
|
10
|
+
personalclaw_client.egg-info/top_level.txt
|
|
11
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
personalclaw_client
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "personalclaw-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "PersonalClaw Client — async Python client for the PersonalClaw Gateway"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
# Deliberately broader than core (>=3.12): the client is a thin, dependency-light
|
|
12
|
+
# library meant to be embeddable in tools on older interpreters.
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
|
|
15
|
+
authors = [
|
|
16
|
+
{ name = "PersonalClaw contributors" },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
keywords = ["personalclaw", "client", "ai", "agent", "gateway"]
|
|
20
|
+
|
|
21
|
+
classifiers = [
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.9",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Operating System :: OS Independent",
|
|
30
|
+
"Topic :: Software Development :: Libraries",
|
|
31
|
+
"Framework :: AsyncIO",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
dependencies = ["aiohttp>=3.9"]
|
|
35
|
+
|
|
36
|
+
# NOTE: [project.urls] MUST come after the bare `dependencies` key — a table
|
|
37
|
+
# header placed earlier would absorb `dependencies` into the urls table (a TOML
|
|
38
|
+
# table runs until the next header), zeroing the runtime deps. (Same footgun
|
|
39
|
+
# fixed in the core pyproject, plan 34 T1.1.)
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://personalclaw.dev"
|
|
42
|
+
Documentation = "https://github.com/PersonalClaw/PersonalClaw/tree/main/docs"
|
|
43
|
+
Source = "https://github.com/PersonalClaw/PersonalClaw"
|
|
44
|
+
Issues = "https://github.com/PersonalClaw/PersonalClaw/issues"
|
|
45
|
+
|
|
46
|
+
[project.optional-dependencies]
|
|
47
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "hypothesis>=6.0", "aiohttp"]
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.packages.find]
|
|
50
|
+
include = ["personalclaw_client*"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
asyncio_mode = "auto"
|
|
54
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Tests for personalclaw_client — mirrors TS @personalclaw/client test coverage."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from personalclaw_client import PersonalClawClient, PersonalClawError
|
|
7
|
+
from personalclaw_client.errors import ErrorCode, http_status_to_code, http_error
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestErrors:
|
|
11
|
+
def test_5xx_maps_to_server_error(self):
|
|
12
|
+
for status in (500, 502, 503):
|
|
13
|
+
assert http_status_to_code(status) == ErrorCode.SERVER_ERROR
|
|
14
|
+
|
|
15
|
+
def test_429_maps_to_rate_limited(self):
|
|
16
|
+
assert http_status_to_code(429) == ErrorCode.RATE_LIMITED
|
|
17
|
+
|
|
18
|
+
def test_404_maps_to_not_found(self):
|
|
19
|
+
assert http_status_to_code(404) == ErrorCode.NOT_FOUND
|
|
20
|
+
|
|
21
|
+
def test_401_maps_to_auth_expired(self):
|
|
22
|
+
assert http_status_to_code(401) == ErrorCode.AUTH_EXPIRED
|
|
23
|
+
|
|
24
|
+
def test_4xx_non_retryable(self):
|
|
25
|
+
for status in (400, 403, 404, 405, 422):
|
|
26
|
+
code = http_status_to_code(status)
|
|
27
|
+
assert code not in (ErrorCode.SERVER_ERROR, ErrorCode.RATE_LIMITED)
|
|
28
|
+
|
|
29
|
+
def test_http_error_has_code_and_message(self):
|
|
30
|
+
err = http_error(500, "internal error")
|
|
31
|
+
assert err.code == ErrorCode.SERVER_ERROR
|
|
32
|
+
assert "internal error" in str(err)
|
|
33
|
+
assert err.status == 500
|
|
34
|
+
|
|
35
|
+
def test_to_dict(self):
|
|
36
|
+
err = PersonalClawError(ErrorCode.NOT_FOUND, "not found", status=404)
|
|
37
|
+
d = err.to_dict()
|
|
38
|
+
assert d["code"] == "NOT_FOUND"
|
|
39
|
+
assert d["message"] == "not found"
|
|
40
|
+
assert d["status"] == 404
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestAuthCheck:
|
|
44
|
+
def test_localhost_no_token_ok(self):
|
|
45
|
+
pc = PersonalClawClient(base_url="http://localhost:7777")
|
|
46
|
+
# Should not raise on construction
|
|
47
|
+
assert pc.base_url == "http://localhost:7777"
|
|
48
|
+
|
|
49
|
+
def test_remote_no_token_raises(self):
|
|
50
|
+
pc = PersonalClawClient(base_url="http://example.com:7777")
|
|
51
|
+
with pytest.raises(PersonalClawError) as exc_info:
|
|
52
|
+
pc._check_auth()
|
|
53
|
+
assert exc_info.value.code == ErrorCode.AUTH_REQUIRED
|
|
54
|
+
|
|
55
|
+
def test_remote_with_token_ok(self):
|
|
56
|
+
pc = PersonalClawClient(base_url="http://example.com:7777", token="test")
|
|
57
|
+
pc._check_auth() # should not raise
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestMessageValidation:
|
|
61
|
+
@pytest.mark.asyncio
|
|
62
|
+
async def test_rejects_long_message(self):
|
|
63
|
+
pc = PersonalClawClient(message_length_limit=100)
|
|
64
|
+
with pytest.raises(PersonalClawError) as exc_info:
|
|
65
|
+
await pc.send_message("session-1", "x" * 101)
|
|
66
|
+
assert exc_info.value.code == ErrorCode.VALIDATION_ERROR
|
|
67
|
+
|
|
68
|
+
@pytest.mark.asyncio
|
|
69
|
+
async def test_rejects_long_notification(self):
|
|
70
|
+
pc = PersonalClawClient(message_length_limit=100)
|
|
71
|
+
with pytest.raises(PersonalClawError) as exc_info:
|
|
72
|
+
await pc.send_notification("x" * 101)
|
|
73
|
+
assert exc_info.value.code == ErrorCode.VALIDATION_ERROR
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestMcpValidation:
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_rejects_empty_name(self):
|
|
79
|
+
pc = PersonalClawClient()
|
|
80
|
+
with pytest.raises(PersonalClawError) as exc_info:
|
|
81
|
+
await pc.register_mcp_server("", "node server.js")
|
|
82
|
+
assert exc_info.value.code == ErrorCode.VALIDATION_ERROR
|
|
83
|
+
|
|
84
|
+
@pytest.mark.asyncio
|
|
85
|
+
async def test_rejects_empty_command(self):
|
|
86
|
+
pc = PersonalClawClient()
|
|
87
|
+
with pytest.raises(PersonalClawError) as exc_info:
|
|
88
|
+
await pc.register_mcp_server("my-server", "")
|
|
89
|
+
assert exc_info.value.code == ErrorCode.VALIDATION_ERROR
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestContextBuffer:
|
|
93
|
+
@pytest.mark.asyncio
|
|
94
|
+
async def test_null_session_buffers_locally(self):
|
|
95
|
+
pc = PersonalClawClient()
|
|
96
|
+
await pc.inject_context(None, "test content")
|
|
97
|
+
assert pc.pending_context_count == 1
|
|
98
|
+
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_buffer_limit_fifo(self):
|
|
101
|
+
pc = PersonalClawClient()
|
|
102
|
+
for i in range(60):
|
|
103
|
+
await pc.inject_context(None, f"entry-{i}")
|
|
104
|
+
assert pc.pending_context_count == 50
|
|
105
|
+
|
|
106
|
+
@pytest.mark.asyncio
|
|
107
|
+
async def test_flush_preserves_failed_entries(self):
|
|
108
|
+
pc = PersonalClawClient(max_retries=0)
|
|
109
|
+
await pc.inject_context(None, "a")
|
|
110
|
+
await pc.inject_context(None, "b")
|
|
111
|
+
assert pc.pending_context_count == 2
|
|
112
|
+
with pytest.raises(Exception):
|
|
113
|
+
await pc.flush_pending_context("session-1")
|
|
114
|
+
# Failed entries preserved for retry
|
|
115
|
+
assert pc.pending_context_count == 2
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TestAppDataDir:
|
|
119
|
+
def test_contains_app_name(self):
|
|
120
|
+
pc = PersonalClawClient(app_name="my-tool")
|
|
121
|
+
d = pc.get_app_data_dir()
|
|
122
|
+
assert "my-tool" in str(d)
|
|
123
|
+
assert ".personalclaw" in str(d)
|
|
124
|
+
assert "apps" in str(d)
|