hypercli-sdk 0.9.0__tar.gz → 1.0.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.
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/PKG-INFO +1 -1
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/__init__.py +5 -6
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/client.py +0 -2
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/jobs.py +0 -4
- hypercli_sdk-1.0.0/hypercli/shell.py +124 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/pyproject.toml +1 -1
- hypercli_sdk-0.9.0/hypercli/agents.py +0 -417
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/.gitignore +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/README.md +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/billing.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/claw.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/config.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/files.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/http.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/instances.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/job/__init__.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/job/base.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/job/comfyui.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/job/gradio.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/keys.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/logs.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/renders.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/user.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/hypercli/x402.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/tests/test_apply_params.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/tests/test_claw.py +0 -0
- {hypercli_sdk-0.9.0 → hypercli_sdk-1.0.0}/tests/test_graph_to_api.py +0 -0
|
@@ -9,10 +9,10 @@ from .x402 import X402Client, X402JobLaunch, X402FlowCreate, X402RenderCreate, F
|
|
|
9
9
|
from .files import File, AsyncFiles
|
|
10
10
|
from .job import BaseJob, ComfyUIJob, GradioJob, apply_params, apply_graph_modes, find_node, find_nodes, load_template, graph_to_api, expand_subgraphs, DEFAULT_OBJECT_INFO
|
|
11
11
|
from .logs import LogStream, stream_logs, fetch_logs
|
|
12
|
-
from .
|
|
12
|
+
from .shell import ShellSession, shell_connect
|
|
13
13
|
from .claw import Claw, ClawKey, ClawPlan, ClawModel
|
|
14
14
|
|
|
15
|
-
__version__ = "0.
|
|
15
|
+
__version__ = "1.0.0"
|
|
16
16
|
__all__ = [
|
|
17
17
|
"HyperCLI",
|
|
18
18
|
"configure",
|
|
@@ -65,10 +65,9 @@ __all__ = [
|
|
|
65
65
|
"LogStream",
|
|
66
66
|
"stream_logs",
|
|
67
67
|
"fetch_logs",
|
|
68
|
-
#
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"ExecResult",
|
|
68
|
+
# Shell
|
|
69
|
+
"ShellSession",
|
|
70
|
+
"shell_connect",
|
|
72
71
|
# HyperClaw
|
|
73
72
|
"Claw",
|
|
74
73
|
"ClawKey",
|
|
@@ -7,7 +7,6 @@ from .user import UserAPI
|
|
|
7
7
|
from .instances import Instances
|
|
8
8
|
from .renders import Renders
|
|
9
9
|
from .files import Files
|
|
10
|
-
from .agents import Agents
|
|
11
10
|
from .claw import Claw
|
|
12
11
|
from .keys import KeysAPI
|
|
13
12
|
|
|
@@ -51,7 +50,6 @@ class HyperCLI:
|
|
|
51
50
|
self._http = HTTPClient(self._api_url, self._api_key)
|
|
52
51
|
|
|
53
52
|
# API namespaces
|
|
54
|
-
self.agents = Agents(self._http, claw_api_key=claw_api_key)
|
|
55
53
|
self.billing = Billing(self._http)
|
|
56
54
|
self.jobs = Jobs(self._http)
|
|
57
55
|
self.user = UserAPI(self._http)
|
|
@@ -134,7 +134,6 @@ class Jobs:
|
|
|
134
134
|
ports: dict[str, int] = None,
|
|
135
135
|
auth: bool = False,
|
|
136
136
|
registry_auth: dict[str, str] = None,
|
|
137
|
-
dockerfile: str = None,
|
|
138
137
|
) -> Job:
|
|
139
138
|
"""Create a new job.
|
|
140
139
|
|
|
@@ -150,7 +149,6 @@ class Jobs:
|
|
|
150
149
|
ports: Ports to expose. Use {"lb": port} for HTTPS load balancer
|
|
151
150
|
auth: Enable Bearer token auth on load balancer (use with ports={"lb": port})
|
|
152
151
|
registry_auth: Private registry credentials {"username": "...", "password": "..."}
|
|
153
|
-
dockerfile: Base64-encoded Dockerfile (overrides docker_image if provided)
|
|
154
152
|
"""
|
|
155
153
|
payload = {
|
|
156
154
|
"docker_image": image,
|
|
@@ -171,8 +169,6 @@ class Jobs:
|
|
|
171
169
|
payload["auth"] = auth
|
|
172
170
|
if registry_auth:
|
|
173
171
|
payload["registry_auth"] = registry_auth
|
|
174
|
-
if dockerfile:
|
|
175
|
-
payload["dockerfile"] = dockerfile
|
|
176
172
|
|
|
177
173
|
data = self._http.post("/api/jobs", json=payload)
|
|
178
174
|
return Job.from_dict(data)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Interactive shell over WebSocket for running job containers.
|
|
2
|
+
|
|
3
|
+
SDK-level: async WebSocket client that connects to the director's
|
|
4
|
+
/orchestra/ws/shell/{job_id}?token={job_key} endpoint.
|
|
5
|
+
|
|
6
|
+
Protocol (text frames):
|
|
7
|
+
Client → Server: raw stdin text, or xterm resize "\x1b[8;{rows};{cols}t"
|
|
8
|
+
Server → Client: raw stdout/stderr text
|
|
9
|
+
"""
|
|
10
|
+
import asyncio
|
|
11
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
12
|
+
|
|
13
|
+
import websockets
|
|
14
|
+
|
|
15
|
+
from .config import get_ws_url
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .client import HyperCLI
|
|
19
|
+
|
|
20
|
+
WS_SHELL_PATH = "/orchestra/ws/shell"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def shell_connect(
|
|
24
|
+
client: "HyperCLI",
|
|
25
|
+
job_id: str,
|
|
26
|
+
job_key: str = None,
|
|
27
|
+
shell: str = "/bin/bash",
|
|
28
|
+
on_output: Callable[[str], None] = None,
|
|
29
|
+
on_close: Callable[[str], None] = None,
|
|
30
|
+
) -> "ShellSession":
|
|
31
|
+
"""Connect to a running job's shell via WebSocket.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
client: HyperCLI client instance
|
|
35
|
+
job_id: Job ID
|
|
36
|
+
job_key: Job key for auth (fetched if None)
|
|
37
|
+
shell: Shell executable (default: /bin/bash)
|
|
38
|
+
on_output: Callback for output data
|
|
39
|
+
on_close: Callback when shell closes (receives reason string)
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
ShellSession object for sending input and managing the connection.
|
|
43
|
+
"""
|
|
44
|
+
if not job_key:
|
|
45
|
+
job = client.jobs.get(job_id)
|
|
46
|
+
job_key = job.job_key
|
|
47
|
+
|
|
48
|
+
ws_url = get_ws_url()
|
|
49
|
+
url = f"{ws_url}{WS_SHELL_PATH}/{job_id}?token={job_key}&shell={shell}"
|
|
50
|
+
|
|
51
|
+
ws = await websockets.connect(
|
|
52
|
+
url,
|
|
53
|
+
ping_interval=30,
|
|
54
|
+
ping_timeout=20,
|
|
55
|
+
close_timeout=5,
|
|
56
|
+
max_size=2**20,
|
|
57
|
+
compression=None,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
session = ShellSession(ws, on_output=on_output, on_close=on_close)
|
|
61
|
+
session._reader_task = asyncio.create_task(session._read_loop())
|
|
62
|
+
return session
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ShellSession:
|
|
66
|
+
"""Async WebSocket shell session."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
ws,
|
|
71
|
+
on_output: Callable[[str], None] = None,
|
|
72
|
+
on_close: Callable[[str], None] = None,
|
|
73
|
+
):
|
|
74
|
+
self._ws = ws
|
|
75
|
+
self._on_output = on_output
|
|
76
|
+
self._on_close = on_close
|
|
77
|
+
self._reader_task: Optional[asyncio.Task] = None
|
|
78
|
+
self._closed = False
|
|
79
|
+
|
|
80
|
+
async def send(self, data: str):
|
|
81
|
+
"""Send stdin data to the shell."""
|
|
82
|
+
if not self._closed:
|
|
83
|
+
await self._ws.send(data)
|
|
84
|
+
|
|
85
|
+
async def resize(self, cols: int, rows: int):
|
|
86
|
+
"""Send terminal resize."""
|
|
87
|
+
if not self._closed:
|
|
88
|
+
await self._ws.send(f"\x1b[8;{rows};{cols}t")
|
|
89
|
+
|
|
90
|
+
async def close(self):
|
|
91
|
+
"""Close the shell session."""
|
|
92
|
+
self._closed = True
|
|
93
|
+
if self._reader_task:
|
|
94
|
+
self._reader_task.cancel()
|
|
95
|
+
try:
|
|
96
|
+
await self._ws.close()
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
async def _read_loop(self):
|
|
101
|
+
"""Read output from WebSocket and dispatch to callback."""
|
|
102
|
+
try:
|
|
103
|
+
async for message in self._ws:
|
|
104
|
+
if self._closed:
|
|
105
|
+
break
|
|
106
|
+
if isinstance(message, bytes):
|
|
107
|
+
message = message.decode(errors="replace")
|
|
108
|
+
if self._on_output:
|
|
109
|
+
self._on_output(message)
|
|
110
|
+
except websockets.ConnectionClosed as e:
|
|
111
|
+
reason = e.reason or f"code {e.code}"
|
|
112
|
+
if self._on_close and not self._closed:
|
|
113
|
+
self._on_close(reason)
|
|
114
|
+
except asyncio.CancelledError:
|
|
115
|
+
pass
|
|
116
|
+
except Exception as e:
|
|
117
|
+
if self._on_close and not self._closed:
|
|
118
|
+
self._on_close(str(e))
|
|
119
|
+
finally:
|
|
120
|
+
self._closed = True
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def closed(self) -> bool:
|
|
124
|
+
return self._closed
|
|
@@ -1,417 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
HyperClaw Agents API — Reef Pod Management
|
|
3
|
-
|
|
4
|
-
Client for HyperClaw backend agent endpoints. Manages OpenClaw desktop containers
|
|
5
|
-
(reef pods) via the authenticated backend API at api.hyperclaw.app/api/agents.
|
|
6
|
-
|
|
7
|
-
The backend proxies to Lagoon internally, handles auth, plan enforcement,
|
|
8
|
-
runtime key generation, and DB persistence.
|
|
9
|
-
"""
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
from dataclasses import dataclass
|
|
13
|
-
from datetime import datetime
|
|
14
|
-
from typing import Optional, Any
|
|
15
|
-
|
|
16
|
-
import httpx
|
|
17
|
-
|
|
18
|
-
from .http import HTTPClient, APIError
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
CLAW_API_BASE = "https://api.hyperclaw.app"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@dataclass
|
|
25
|
-
class ReefPod:
|
|
26
|
-
"""A reef pod (OpenClaw desktop container)."""
|
|
27
|
-
id: str # Agent UUID from backend
|
|
28
|
-
user_id: str
|
|
29
|
-
pod_id: str
|
|
30
|
-
pod_name: str
|
|
31
|
-
state: str
|
|
32
|
-
hostname: Optional[str] = None
|
|
33
|
-
jwt_token: Optional[str] = None
|
|
34
|
-
jwt_expires_at: Optional[datetime] = None
|
|
35
|
-
started_at: Optional[datetime] = None
|
|
36
|
-
stopped_at: Optional[datetime] = None
|
|
37
|
-
last_error: Optional[str] = None
|
|
38
|
-
created_at: Optional[datetime] = None
|
|
39
|
-
updated_at: Optional[datetime] = None
|
|
40
|
-
|
|
41
|
-
@classmethod
|
|
42
|
-
def from_dict(cls, data: dict) -> ReefPod:
|
|
43
|
-
def _parse_dt(val):
|
|
44
|
-
if isinstance(val, str) and val:
|
|
45
|
-
return datetime.fromisoformat(val.replace("Z", "+00:00"))
|
|
46
|
-
return None
|
|
47
|
-
|
|
48
|
-
return cls(
|
|
49
|
-
id=data.get("id", ""),
|
|
50
|
-
user_id=data.get("user_id", ""),
|
|
51
|
-
pod_id=data.get("pod_id", ""),
|
|
52
|
-
pod_name=data.get("pod_name", ""),
|
|
53
|
-
state=data.get("state", "unknown"),
|
|
54
|
-
hostname=data.get("hostname"),
|
|
55
|
-
jwt_token=data.get("jwt_token"),
|
|
56
|
-
jwt_expires_at=_parse_dt(data.get("jwt_expires_at")),
|
|
57
|
-
started_at=_parse_dt(data.get("started_at")),
|
|
58
|
-
stopped_at=_parse_dt(data.get("stopped_at")),
|
|
59
|
-
last_error=data.get("last_error"),
|
|
60
|
-
created_at=_parse_dt(data.get("created_at")),
|
|
61
|
-
updated_at=_parse_dt(data.get("updated_at")),
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
@property
|
|
65
|
-
def vnc_url(self) -> Optional[str]:
|
|
66
|
-
if self.hostname:
|
|
67
|
-
return f"https://{self.hostname}"
|
|
68
|
-
return None
|
|
69
|
-
|
|
70
|
-
@property
|
|
71
|
-
def shell_url(self) -> Optional[str]:
|
|
72
|
-
if self.hostname:
|
|
73
|
-
return f"https://shell-{self.hostname}"
|
|
74
|
-
return None
|
|
75
|
-
|
|
76
|
-
@property
|
|
77
|
-
def executor_url(self) -> Optional[str]:
|
|
78
|
-
return self.shell_url
|
|
79
|
-
|
|
80
|
-
@property
|
|
81
|
-
def is_running(self) -> bool:
|
|
82
|
-
return self.state == "running"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
@dataclass
|
|
86
|
-
class ExecResult:
|
|
87
|
-
"""Result of a one-shot command execution."""
|
|
88
|
-
exit_code: int
|
|
89
|
-
stdout: str
|
|
90
|
-
stderr: str
|
|
91
|
-
|
|
92
|
-
@classmethod
|
|
93
|
-
def from_dict(cls, data: dict) -> ExecResult:
|
|
94
|
-
return cls(
|
|
95
|
-
exit_code=data.get("exit_code", -1),
|
|
96
|
-
stdout=data.get("stdout", ""),
|
|
97
|
-
stderr=data.get("stderr", ""),
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
class Agents:
|
|
102
|
-
"""
|
|
103
|
-
HyperClaw Agents API — manage reef pods (OpenClaw desktop containers).
|
|
104
|
-
|
|
105
|
-
Uses the authenticated backend API (api.hyperclaw.app/api/agents).
|
|
106
|
-
Auth: pass your HyperClaw API key (sk-...) as the claw_api_key.
|
|
107
|
-
|
|
108
|
-
Usage:
|
|
109
|
-
from hypercli import HyperCLI
|
|
110
|
-
client = HyperCLI(api_key="...", claw_api_key="sk-...")
|
|
111
|
-
|
|
112
|
-
# Launch
|
|
113
|
-
pod = client.agents.create()
|
|
114
|
-
print(f"Desktop: {pod.vnc_url}")
|
|
115
|
-
|
|
116
|
-
# Execute a command
|
|
117
|
-
result = client.agents.exec(pod, "echo hello")
|
|
118
|
-
|
|
119
|
-
# List
|
|
120
|
-
pods = client.agents.list()
|
|
121
|
-
|
|
122
|
-
# Stop
|
|
123
|
-
client.agents.stop(pod.id)
|
|
124
|
-
"""
|
|
125
|
-
|
|
126
|
-
def __init__(self, http: HTTPClient, claw_api_key: str = None, claw_api_base: str = None):
|
|
127
|
-
self._http = http
|
|
128
|
-
self._api_key = claw_api_key or http.api_key
|
|
129
|
-
self._api_base = (claw_api_base or CLAW_API_BASE).rstrip("/")
|
|
130
|
-
|
|
131
|
-
@property
|
|
132
|
-
def _headers(self) -> dict:
|
|
133
|
-
return {
|
|
134
|
-
"Authorization": f"Bearer {self._api_key}",
|
|
135
|
-
"Content-Type": "application/json",
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
def _get(self, path: str, params: dict = None) -> Any:
|
|
139
|
-
with httpx.Client(timeout=30) as client:
|
|
140
|
-
resp = client.get(f"{self._api_base}{path}", headers=self._headers, params=params)
|
|
141
|
-
if resp.status_code >= 400:
|
|
142
|
-
try:
|
|
143
|
-
detail = resp.json().get("detail", resp.text)
|
|
144
|
-
except Exception:
|
|
145
|
-
detail = resp.text
|
|
146
|
-
raise APIError(resp.status_code, detail)
|
|
147
|
-
return resp.json()
|
|
148
|
-
|
|
149
|
-
def _post(self, path: str, json: dict = None) -> Any:
|
|
150
|
-
with httpx.Client(timeout=30) as client:
|
|
151
|
-
resp = client.post(f"{self._api_base}{path}", headers=self._headers, json=json)
|
|
152
|
-
if resp.status_code >= 400:
|
|
153
|
-
try:
|
|
154
|
-
detail = resp.json().get("detail", resp.text)
|
|
155
|
-
except Exception:
|
|
156
|
-
detail = resp.text
|
|
157
|
-
raise APIError(resp.status_code, detail)
|
|
158
|
-
return resp.json()
|
|
159
|
-
|
|
160
|
-
def _delete(self, path: str) -> Any:
|
|
161
|
-
with httpx.Client(timeout=30) as client:
|
|
162
|
-
resp = client.delete(f"{self._api_base}{path}", headers=self._headers)
|
|
163
|
-
if resp.status_code >= 400:
|
|
164
|
-
try:
|
|
165
|
-
detail = resp.json().get("detail", resp.text)
|
|
166
|
-
except Exception:
|
|
167
|
-
detail = resp.text
|
|
168
|
-
raise APIError(resp.status_code, detail)
|
|
169
|
-
return resp.json()
|
|
170
|
-
|
|
171
|
-
# -----------------------------------------------------------------------
|
|
172
|
-
# Agent lifecycle (HyperClaw backend → Lagoon)
|
|
173
|
-
# -----------------------------------------------------------------------
|
|
174
|
-
|
|
175
|
-
def create(self, config: dict = None) -> ReefPod:
|
|
176
|
-
"""Create a new agent (provisions a reef pod via the backend).
|
|
177
|
-
|
|
178
|
-
The backend handles: auth, plan enforcement, runtime key generation,
|
|
179
|
-
Lagoon pod creation, and DB persistence.
|
|
180
|
-
|
|
181
|
-
Args:
|
|
182
|
-
config: Optional config overrides.
|
|
183
|
-
|
|
184
|
-
Returns:
|
|
185
|
-
ReefPod with connection details.
|
|
186
|
-
"""
|
|
187
|
-
data = self._post("/api/agents", json={"config": config or {}})
|
|
188
|
-
return ReefPod.from_dict(data)
|
|
189
|
-
|
|
190
|
-
def list(self) -> list[ReefPod]:
|
|
191
|
-
"""List all agents for the authenticated user.
|
|
192
|
-
|
|
193
|
-
Returns:
|
|
194
|
-
List of ReefPod objects.
|
|
195
|
-
"""
|
|
196
|
-
data = self._get("/api/agents")
|
|
197
|
-
items = data.get("items", data) if isinstance(data, dict) else data
|
|
198
|
-
return [ReefPod.from_dict(p) for p in items]
|
|
199
|
-
|
|
200
|
-
def get(self, agent_id: str) -> ReefPod:
|
|
201
|
-
"""Get agent details by ID (refreshes status from Lagoon).
|
|
202
|
-
|
|
203
|
-
Args:
|
|
204
|
-
agent_id: Agent UUID.
|
|
205
|
-
|
|
206
|
-
Returns:
|
|
207
|
-
ReefPod with current status.
|
|
208
|
-
"""
|
|
209
|
-
data = self._get(f"/api/agents/{agent_id}")
|
|
210
|
-
return ReefPod.from_dict(data)
|
|
211
|
-
|
|
212
|
-
def start(self, agent_id: str) -> ReefPod:
|
|
213
|
-
"""Start a previously stopped agent.
|
|
214
|
-
|
|
215
|
-
Args:
|
|
216
|
-
agent_id: Agent UUID.
|
|
217
|
-
|
|
218
|
-
Returns:
|
|
219
|
-
ReefPod with new pod details.
|
|
220
|
-
"""
|
|
221
|
-
data = self._post(f"/api/agents/{agent_id}/start")
|
|
222
|
-
return ReefPod.from_dict(data)
|
|
223
|
-
|
|
224
|
-
def stop(self, agent_id: str) -> ReefPod:
|
|
225
|
-
"""Stop an agent (tears down pod, keeps DB record).
|
|
226
|
-
|
|
227
|
-
Args:
|
|
228
|
-
agent_id: Agent UUID.
|
|
229
|
-
|
|
230
|
-
Returns:
|
|
231
|
-
ReefPod in stopped state.
|
|
232
|
-
"""
|
|
233
|
-
data = self._post(f"/api/agents/{agent_id}/stop")
|
|
234
|
-
return ReefPod.from_dict(data)
|
|
235
|
-
|
|
236
|
-
def delete(self, agent_id: str) -> dict:
|
|
237
|
-
"""Delete an agent entirely (pod + DB record).
|
|
238
|
-
|
|
239
|
-
Args:
|
|
240
|
-
agent_id: Agent UUID.
|
|
241
|
-
|
|
242
|
-
Returns:
|
|
243
|
-
Deletion status dict.
|
|
244
|
-
"""
|
|
245
|
-
return self._delete(f"/api/agents/{agent_id}")
|
|
246
|
-
|
|
247
|
-
def refresh_token(self, agent_id: str) -> dict:
|
|
248
|
-
"""Refresh the JWT token for an agent.
|
|
249
|
-
|
|
250
|
-
Args:
|
|
251
|
-
agent_id: Agent UUID.
|
|
252
|
-
|
|
253
|
-
Returns:
|
|
254
|
-
Dict with agent_id, pod_id, token, expires_at.
|
|
255
|
-
"""
|
|
256
|
-
return self._get(f"/api/agents/{agent_id}/token")
|
|
257
|
-
|
|
258
|
-
# -----------------------------------------------------------------------
|
|
259
|
-
# Executor API (direct to reef pod via shell-{hostname})
|
|
260
|
-
# -----------------------------------------------------------------------
|
|
261
|
-
|
|
262
|
-
def _executor_headers(self, pod: ReefPod) -> dict:
|
|
263
|
-
h = {}
|
|
264
|
-
if pod.jwt_token:
|
|
265
|
-
h["Authorization"] = f"Bearer {pod.jwt_token}"
|
|
266
|
-
h["Cookie"] = f"{pod.pod_name}-token={pod.jwt_token}"
|
|
267
|
-
return h
|
|
268
|
-
|
|
269
|
-
def exec(self, pod: ReefPod, command: str, timeout: int = 30) -> ExecResult:
|
|
270
|
-
"""Execute a one-shot command on a reef pod via the executor API.
|
|
271
|
-
|
|
272
|
-
Args:
|
|
273
|
-
pod: ReefPod to execute on (needs jwt_token).
|
|
274
|
-
command: Shell command to run.
|
|
275
|
-
timeout: Command timeout in seconds.
|
|
276
|
-
|
|
277
|
-
Returns:
|
|
278
|
-
ExecResult with exit_code, stdout, stderr.
|
|
279
|
-
"""
|
|
280
|
-
if not pod.executor_url:
|
|
281
|
-
raise ValueError("Pod has no executor URL (missing hostname)")
|
|
282
|
-
with httpx.Client(timeout=max(timeout + 5, 35)) as client:
|
|
283
|
-
resp = client.post(
|
|
284
|
-
f"{pod.executor_url}/exec",
|
|
285
|
-
headers=self._executor_headers(pod),
|
|
286
|
-
json={"command": command, "timeout": timeout},
|
|
287
|
-
)
|
|
288
|
-
if resp.status_code >= 400:
|
|
289
|
-
try:
|
|
290
|
-
detail = resp.json().get("detail", resp.text)
|
|
291
|
-
except Exception:
|
|
292
|
-
detail = resp.text
|
|
293
|
-
raise APIError(resp.status_code, detail)
|
|
294
|
-
return ExecResult.from_dict(resp.json())
|
|
295
|
-
|
|
296
|
-
def health(self, pod: ReefPod) -> dict:
|
|
297
|
-
"""Check executor health on a pod."""
|
|
298
|
-
if not pod.executor_url:
|
|
299
|
-
raise ValueError("Pod has no executor URL")
|
|
300
|
-
with httpx.Client(timeout=10) as client:
|
|
301
|
-
resp = client.get(
|
|
302
|
-
f"{pod.executor_url}/health",
|
|
303
|
-
headers=self._executor_headers(pod),
|
|
304
|
-
)
|
|
305
|
-
if resp.status_code >= 400:
|
|
306
|
-
raise APIError(resp.status_code, resp.text)
|
|
307
|
-
return resp.json()
|
|
308
|
-
|
|
309
|
-
def files_list(self, pod: ReefPod, path: str = ".") -> list[dict]:
|
|
310
|
-
"""List files on a pod."""
|
|
311
|
-
if not pod.executor_url:
|
|
312
|
-
raise ValueError("Pod has no executor URL")
|
|
313
|
-
with httpx.Client(timeout=10) as client:
|
|
314
|
-
resp = client.get(
|
|
315
|
-
f"{pod.executor_url}/files",
|
|
316
|
-
headers=self._executor_headers(pod),
|
|
317
|
-
params={"path": path},
|
|
318
|
-
)
|
|
319
|
-
if resp.status_code >= 400:
|
|
320
|
-
raise APIError(resp.status_code, resp.text)
|
|
321
|
-
return resp.json().get("entries", [])
|
|
322
|
-
|
|
323
|
-
def file_read(self, pod: ReefPod, path: str) -> str:
|
|
324
|
-
"""Read a file from a pod."""
|
|
325
|
-
if not pod.executor_url:
|
|
326
|
-
raise ValueError("Pod has no executor URL")
|
|
327
|
-
with httpx.Client(timeout=10) as client:
|
|
328
|
-
resp = client.get(
|
|
329
|
-
f"{pod.executor_url}/files/read",
|
|
330
|
-
headers=self._executor_headers(pod),
|
|
331
|
-
params={"path": path},
|
|
332
|
-
)
|
|
333
|
-
if resp.status_code >= 400:
|
|
334
|
-
raise APIError(resp.status_code, resp.text)
|
|
335
|
-
return resp.text
|
|
336
|
-
|
|
337
|
-
def file_write(self, pod: ReefPod, path: str, content: str) -> dict:
|
|
338
|
-
"""Write a file to a pod."""
|
|
339
|
-
if not pod.executor_url:
|
|
340
|
-
raise ValueError("Pod has no executor URL")
|
|
341
|
-
with httpx.Client(timeout=10) as client:
|
|
342
|
-
resp = client.put(
|
|
343
|
-
f"{pod.executor_url}/files/write",
|
|
344
|
-
headers=self._executor_headers(pod),
|
|
345
|
-
params={"path": path},
|
|
346
|
-
content=content.encode(),
|
|
347
|
-
)
|
|
348
|
-
if resp.status_code >= 400:
|
|
349
|
-
raise APIError(resp.status_code, resp.text)
|
|
350
|
-
return resp.json()
|
|
351
|
-
|
|
352
|
-
def chat_stream(self, pod: ReefPod, messages: list[dict], model: str = "hyperclaw/kimi-k2.5"):
|
|
353
|
-
"""Stream chat completions from the pod's OpenClaw gateway via executor proxy.
|
|
354
|
-
|
|
355
|
-
Yields content delta strings as they arrive.
|
|
356
|
-
"""
|
|
357
|
-
if not pod.executor_url:
|
|
358
|
-
raise ValueError("Pod has no executor URL")
|
|
359
|
-
|
|
360
|
-
body = {
|
|
361
|
-
"model": model,
|
|
362
|
-
"messages": messages,
|
|
363
|
-
"stream": True,
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
with httpx.Client(timeout=300) as client:
|
|
367
|
-
with client.stream(
|
|
368
|
-
"POST",
|
|
369
|
-
f"{pod.executor_url}/chat",
|
|
370
|
-
headers=self._executor_headers(pod),
|
|
371
|
-
json=body,
|
|
372
|
-
) as resp:
|
|
373
|
-
if resp.status_code >= 400:
|
|
374
|
-
raise APIError(resp.status_code, resp.read().decode())
|
|
375
|
-
for line in resp.iter_lines():
|
|
376
|
-
line = line.strip()
|
|
377
|
-
if not line or not line.startswith("data: "):
|
|
378
|
-
continue
|
|
379
|
-
data = line[6:]
|
|
380
|
-
if data == "[DONE]":
|
|
381
|
-
break
|
|
382
|
-
try:
|
|
383
|
-
import json
|
|
384
|
-
chunk = json.loads(data)
|
|
385
|
-
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
|
386
|
-
content = delta.get("content")
|
|
387
|
-
if content:
|
|
388
|
-
yield content
|
|
389
|
-
except (json.JSONDecodeError, IndexError, KeyError):
|
|
390
|
-
continue
|
|
391
|
-
|
|
392
|
-
def logs_stream(self, pod: ReefPod, lines: int = 100, follow: bool = True):
|
|
393
|
-
"""Stream logs from the pod via executor SSE.
|
|
394
|
-
|
|
395
|
-
Yields log lines as they arrive.
|
|
396
|
-
"""
|
|
397
|
-
if not pod.executor_url:
|
|
398
|
-
raise ValueError("Pod has no executor URL")
|
|
399
|
-
|
|
400
|
-
params = {"lines": lines, "follow": "true" if follow else "false"}
|
|
401
|
-
|
|
402
|
-
with httpx.Client(timeout=None) as client:
|
|
403
|
-
with client.stream(
|
|
404
|
-
"GET",
|
|
405
|
-
f"{pod.executor_url}/logs",
|
|
406
|
-
headers=self._executor_headers(pod),
|
|
407
|
-
params=params,
|
|
408
|
-
) as resp:
|
|
409
|
-
if resp.status_code >= 400:
|
|
410
|
-
raise APIError(resp.status_code, resp.read().decode())
|
|
411
|
-
for line in resp.iter_lines():
|
|
412
|
-
line = line.strip()
|
|
413
|
-
if line.startswith("data: "):
|
|
414
|
-
data = line[6:]
|
|
415
|
-
if data == "[keepalive]":
|
|
416
|
-
continue
|
|
417
|
-
yield data
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|