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.
@@ -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,7 @@
1
+ aiohttp>=3.9
2
+
3
+ [dev]
4
+ pytest>=8.0
5
+ pytest-asyncio>=0.23
6
+ hypothesis>=6.0
7
+ aiohttp
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)