clawifi 0.1.0__py3-none-any.whl
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.
- clawifi/__init__.py +13 -0
- clawifi/cli.py +343 -0
- clawifi/client.py +336 -0
- clawifi/errors.py +46 -0
- clawifi/mcp_server.py +118 -0
- clawifi/py.typed +1 -0
- clawifi/testing.py +70 -0
- clawifi-0.1.0.dist-info/METADATA +87 -0
- clawifi-0.1.0.dist-info/RECORD +13 -0
- clawifi-0.1.0.dist-info/WHEEL +5 -0
- clawifi-0.1.0.dist-info/entry_points.txt +3 -0
- clawifi-0.1.0.dist-info/licenses/LICENSE +21 -0
- clawifi-0.1.0.dist-info/top_level.txt +1 -0
clawifi/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from clawifi.client import AsyncClawifi, Clawifi, ClawifiConfig
|
|
4
|
+
from clawifi.errors import ClawifiAPIError, ClawifiError, ClawifiTransportError
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AsyncClawifi",
|
|
8
|
+
"Clawifi",
|
|
9
|
+
"ClawifiAPIError",
|
|
10
|
+
"ClawifiConfig",
|
|
11
|
+
"ClawifiError",
|
|
12
|
+
"ClawifiTransportError",
|
|
13
|
+
]
|
clawifi/cli.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_API_BASE = "https://api.clawifi.qzz.io"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(argv: list[str] | None = None) -> None:
|
|
14
|
+
parser = argparse.ArgumentParser(prog="clawifi", description="Clawifi cloud CLI for agent internet reach.")
|
|
15
|
+
parser.add_argument("--api-base", default=os.getenv("CLAWIFI_API_BASE", DEFAULT_API_BASE))
|
|
16
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
17
|
+
|
|
18
|
+
signup = subcommands.add_parser("signup", help="Create a Clawifi account and first API key.")
|
|
19
|
+
signup.add_argument("--email", required=True)
|
|
20
|
+
signup.add_argument("--password", required=True)
|
|
21
|
+
signup.add_argument("--display-name")
|
|
22
|
+
|
|
23
|
+
login = subcommands.add_parser("login", help="Create an account session token.")
|
|
24
|
+
login.add_argument("--email", required=True)
|
|
25
|
+
login.add_argument("--password", required=True)
|
|
26
|
+
|
|
27
|
+
verify = subcommands.add_parser("verify-email", help="Verify your Clawifi email token and create an API key.")
|
|
28
|
+
verify.add_argument("--token", required=True)
|
|
29
|
+
|
|
30
|
+
quicktest = subcommands.add_parser("quicktest", help="Verify your API key against the Clawifi API.")
|
|
31
|
+
quicktest.add_argument("--url", default="https://example.com")
|
|
32
|
+
|
|
33
|
+
mcp = subcommands.add_parser("mcp", help="Print MCP setup helpers.")
|
|
34
|
+
mcp_subcommands = mcp.add_subparsers(dest="mcp_command", required=True)
|
|
35
|
+
mcp_subcommands.add_parser("config", help="Print a minimal MCP server config.")
|
|
36
|
+
|
|
37
|
+
browser = subcommands.add_parser("browser", help="Manage Clawifi browser runtime sessions.")
|
|
38
|
+
browser_subcommands = browser.add_subparsers(dest="browser_command", required=True)
|
|
39
|
+
browser_pair = browser_subcommands.add_parser("pair", help="Create a short-lived WebBridge pairing code.")
|
|
40
|
+
browser_pair.add_argument(
|
|
41
|
+
"--local-api-base",
|
|
42
|
+
default=os.getenv("CLAWIFI_LOCAL_API_BASE", "http://127.0.0.1:8000"),
|
|
43
|
+
)
|
|
44
|
+
browser_doctor = browser_subcommands.add_parser(
|
|
45
|
+
"doctor", help="Run browser runtime and agent configuration diagnostics."
|
|
46
|
+
)
|
|
47
|
+
browser_doctor.add_argument(
|
|
48
|
+
"--verbose",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help="Include the complete check report (currently the default output).",
|
|
51
|
+
)
|
|
52
|
+
browser_doctor.add_argument(
|
|
53
|
+
"--local-api-base",
|
|
54
|
+
default=os.getenv("CLAWIFI_LOCAL_API_BASE", "http://127.0.0.1:8000"),
|
|
55
|
+
)
|
|
56
|
+
browser_start = browser_subcommands.add_parser("start", help="Start a managed headed or headless browser session.")
|
|
57
|
+
browser_start.add_argument("--url", required=True)
|
|
58
|
+
browser_start.add_argument("--mode", choices=("headless", "headed"), default="headless")
|
|
59
|
+
browser_start.add_argument("--profile-id")
|
|
60
|
+
browser_start.add_argument("--ttl-seconds", type=int, default=300)
|
|
61
|
+
browser_start.add_argument(
|
|
62
|
+
"--local-api-base",
|
|
63
|
+
default=os.getenv("CLAWIFI_LOCAL_API_BASE", "http://127.0.0.1:8000"),
|
|
64
|
+
)
|
|
65
|
+
browser_status = browser_subcommands.add_parser("status", help="List browser sessions or inspect one session.")
|
|
66
|
+
browser_status.add_argument("session_id", nargs="?")
|
|
67
|
+
browser_status.add_argument(
|
|
68
|
+
"--local-api-base",
|
|
69
|
+
default=os.getenv("CLAWIFI_LOCAL_API_BASE", "http://127.0.0.1:8000"),
|
|
70
|
+
)
|
|
71
|
+
browser_stop = browser_subcommands.add_parser("stop", help="Close one or all browser sessions.")
|
|
72
|
+
browser_stop_target = browser_stop.add_mutually_exclusive_group(required=True)
|
|
73
|
+
browser_stop_target.add_argument("session_id", nargs="?")
|
|
74
|
+
browser_stop_target.add_argument("--all", action="store_true")
|
|
75
|
+
browser_stop.add_argument(
|
|
76
|
+
"--local-api-base",
|
|
77
|
+
default=os.getenv("CLAWIFI_LOCAL_API_BASE", "http://127.0.0.1:8000"),
|
|
78
|
+
)
|
|
79
|
+
browser_smoke = browser_subcommands.add_parser("smoke", help="Create, verify, and always close a browser session.")
|
|
80
|
+
browser_smoke.add_argument("--url", default="https://example.com")
|
|
81
|
+
browser_smoke.add_argument("--mode", choices=("headless", "headed"), default="headless")
|
|
82
|
+
browser_smoke.add_argument("--profile-id")
|
|
83
|
+
browser_smoke.add_argument("--ttl-seconds", type=int, default=300)
|
|
84
|
+
browser_smoke.add_argument(
|
|
85
|
+
"--local-api-base",
|
|
86
|
+
default=os.getenv("CLAWIFI_LOCAL_API_BASE", "http://127.0.0.1:8000"),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
monitors = subcommands.add_parser("monitors", help="Manage Evidence Monitor checks.")
|
|
90
|
+
monitor_subcommands = monitors.add_subparsers(dest="monitor_command", required=True)
|
|
91
|
+
monitor_create = monitor_subcommands.add_parser("create", help="Create a monitor.")
|
|
92
|
+
monitor_create.add_argument("--name", required=True)
|
|
93
|
+
monitor_create.add_argument("--url", required=True)
|
|
94
|
+
monitor_create.add_argument("--interval-seconds", type=int, default=900)
|
|
95
|
+
monitor_create.add_argument("--format", dest="formats", action="append", default=None)
|
|
96
|
+
monitor_create.add_argument("--diff-mode", dest="diff_modes", action="append", default=None)
|
|
97
|
+
monitor_create.add_argument("--json", action="store_true")
|
|
98
|
+
monitor_list = monitor_subcommands.add_parser("list", help="List monitors.")
|
|
99
|
+
monitor_list.add_argument("--include-deleted", action="store_true")
|
|
100
|
+
monitor_list.add_argument("--json", action="store_true")
|
|
101
|
+
monitor_get = monitor_subcommands.add_parser("get", help="Get one monitor.")
|
|
102
|
+
monitor_get.add_argument("monitor_id")
|
|
103
|
+
monitor_get.add_argument("--json", action="store_true")
|
|
104
|
+
monitor_pause = monitor_subcommands.add_parser("pause", help="Pause a monitor.")
|
|
105
|
+
monitor_pause.add_argument("monitor_id")
|
|
106
|
+
monitor_pause.add_argument("--json", action="store_true")
|
|
107
|
+
monitor_resume = monitor_subcommands.add_parser("resume", help="Resume a monitor.")
|
|
108
|
+
monitor_resume.add_argument("monitor_id")
|
|
109
|
+
monitor_resume.add_argument("--run-immediately", action="store_true")
|
|
110
|
+
monitor_resume.add_argument("--json", action="store_true")
|
|
111
|
+
monitor_delete = monitor_subcommands.add_parser("delete", help="Delete a monitor.")
|
|
112
|
+
monitor_delete.add_argument("monitor_id")
|
|
113
|
+
monitor_delete.add_argument("--json", action="store_true")
|
|
114
|
+
monitor_run = monitor_subcommands.add_parser("run", help="Run a monitor now.")
|
|
115
|
+
monitor_run.add_argument("monitor_id")
|
|
116
|
+
monitor_run.add_argument("--json", action="store_true")
|
|
117
|
+
|
|
118
|
+
args = parser.parse_args(argv)
|
|
119
|
+
api_base = args.api_base.rstrip("/")
|
|
120
|
+
|
|
121
|
+
if args.command == "signup":
|
|
122
|
+
payload = _request(
|
|
123
|
+
"POST",
|
|
124
|
+
f"{api_base}/auth/signup",
|
|
125
|
+
json_body={"email": args.email, "password": args.password, "display_name": args.display_name},
|
|
126
|
+
)
|
|
127
|
+
print("Clawifi account created.")
|
|
128
|
+
print(f"Email: {payload['account']['email']}")
|
|
129
|
+
print(f"Credits: {payload['credits']['available']}")
|
|
130
|
+
if payload.get("requires_email_verification"):
|
|
131
|
+
print("Email verification is required before an API key is issued.")
|
|
132
|
+
return
|
|
133
|
+
print("Set this key in your agent runtime:")
|
|
134
|
+
print(f"export CLAWIFI_API_KEY={payload['api_key']}")
|
|
135
|
+
raise SystemExit(0)
|
|
136
|
+
|
|
137
|
+
if args.command == "verify-email":
|
|
138
|
+
payload = _request("POST", f"{api_base}/auth/verify-email", json_body={"token": args.token})
|
|
139
|
+
print("Clawifi email verified.")
|
|
140
|
+
print(f"Email: {payload['account']['email']}")
|
|
141
|
+
print(f"Credits: {payload['credits']['available']}")
|
|
142
|
+
print(f"export CLAWIFI_SESSION_TOKEN={payload['session_token']}")
|
|
143
|
+
print(f"export CLAWIFI_API_KEY={payload['api_key']}")
|
|
144
|
+
raise SystemExit(0)
|
|
145
|
+
|
|
146
|
+
if args.command == "login":
|
|
147
|
+
payload = _request(
|
|
148
|
+
"POST",
|
|
149
|
+
f"{api_base}/auth/login",
|
|
150
|
+
json_body={"email": args.email, "password": args.password},
|
|
151
|
+
)
|
|
152
|
+
print("Clawifi session created.")
|
|
153
|
+
print(f"Session expires: {payload['session_expires_at']}")
|
|
154
|
+
print(f"export CLAWIFI_SESSION_TOKEN={payload['session_token']}")
|
|
155
|
+
raise SystemExit(0)
|
|
156
|
+
|
|
157
|
+
if args.command == "quicktest":
|
|
158
|
+
api_key = _required_api_key()
|
|
159
|
+
payload = _request(
|
|
160
|
+
"POST",
|
|
161
|
+
f"{api_base}/fetch",
|
|
162
|
+
token=api_key,
|
|
163
|
+
json_body={"url": args.url, "timeout_seconds": 15},
|
|
164
|
+
)
|
|
165
|
+
print(json.dumps({"ok": True, "success": payload.get("success"), "source": payload.get("source")}, indent=2))
|
|
166
|
+
raise SystemExit(0)
|
|
167
|
+
|
|
168
|
+
if args.command == "mcp" and args.mcp_command == "config":
|
|
169
|
+
_required_api_key()
|
|
170
|
+
print(json.dumps({
|
|
171
|
+
"mcpServers": {
|
|
172
|
+
"clawifi": {
|
|
173
|
+
"command": "clawifi-mcp",
|
|
174
|
+
"env": {
|
|
175
|
+
"CLAWIFI_API_KEY": "${CLAWIFI_API_KEY}",
|
|
176
|
+
"CLAWIFI_API_BASE": api_base
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}, indent=2))
|
|
181
|
+
raise SystemExit(0)
|
|
182
|
+
|
|
183
|
+
if args.command == "browser":
|
|
184
|
+
api_key = _required_api_key()
|
|
185
|
+
local_api_base = args.local_api_base.rstrip("/")
|
|
186
|
+
if args.browser_command == "pair":
|
|
187
|
+
payload = _request(
|
|
188
|
+
"POST",
|
|
189
|
+
f"{local_api_base}/browser/bridge/pairing",
|
|
190
|
+
token=api_key,
|
|
191
|
+
json_body={},
|
|
192
|
+
)
|
|
193
|
+
print("One-time WebBridge pairing code:")
|
|
194
|
+
print(payload["code"])
|
|
195
|
+
print(f"Expires in {payload['expires_in_seconds']} seconds.")
|
|
196
|
+
print("Paste it into the Clawifi WebBridge extension. It can be used once.")
|
|
197
|
+
elif args.browser_command == "doctor":
|
|
198
|
+
payload = _request("GET", f"{local_api_base}/browser/doctor", token=api_key)
|
|
199
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
200
|
+
if not payload.get("ok", False):
|
|
201
|
+
raise SystemExit(1)
|
|
202
|
+
elif args.browser_command == "start":
|
|
203
|
+
request_payload = {
|
|
204
|
+
"url": args.url,
|
|
205
|
+
"mode": args.mode,
|
|
206
|
+
"ttl_seconds": args.ttl_seconds,
|
|
207
|
+
**({"profile_id": args.profile_id} if args.profile_id else {}),
|
|
208
|
+
}
|
|
209
|
+
payload = _request(
|
|
210
|
+
"POST",
|
|
211
|
+
f"{local_api_base}/browser/sessions",
|
|
212
|
+
token=api_key,
|
|
213
|
+
json_body=request_payload,
|
|
214
|
+
)
|
|
215
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
216
|
+
elif args.browser_command == "status":
|
|
217
|
+
suffix = f"/{args.session_id}" if args.session_id else ""
|
|
218
|
+
payload = _request("GET", f"{local_api_base}/browser/sessions{suffix}", token=api_key)
|
|
219
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
220
|
+
elif args.browser_command == "stop":
|
|
221
|
+
if args.all:
|
|
222
|
+
payload = _request(
|
|
223
|
+
"DELETE",
|
|
224
|
+
f"{local_api_base}/browser/sessions",
|
|
225
|
+
token=api_key,
|
|
226
|
+
json_body={"session_ids": None},
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
payload = _request(
|
|
230
|
+
"DELETE",
|
|
231
|
+
f"{local_api_base}/browser/sessions/{args.session_id}",
|
|
232
|
+
token=api_key,
|
|
233
|
+
)
|
|
234
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
235
|
+
elif args.browser_command == "smoke":
|
|
236
|
+
request_payload = {
|
|
237
|
+
"url": args.url,
|
|
238
|
+
"mode": args.mode,
|
|
239
|
+
"ttl_seconds": args.ttl_seconds,
|
|
240
|
+
**({"profile_id": args.profile_id} if args.profile_id else {}),
|
|
241
|
+
}
|
|
242
|
+
created = _request(
|
|
243
|
+
"POST",
|
|
244
|
+
f"{local_api_base}/browser/sessions",
|
|
245
|
+
token=api_key,
|
|
246
|
+
json_body=request_payload,
|
|
247
|
+
)
|
|
248
|
+
session_id = created.get("session_id")
|
|
249
|
+
cleanup = None
|
|
250
|
+
try:
|
|
251
|
+
if not session_id or created.get("status") != "ready":
|
|
252
|
+
raise SystemExit("Browser smoke failed: session did not become ready")
|
|
253
|
+
finally:
|
|
254
|
+
if session_id:
|
|
255
|
+
cleanup = _request(
|
|
256
|
+
"DELETE",
|
|
257
|
+
f"{local_api_base}/browser/sessions/{session_id}",
|
|
258
|
+
token=api_key,
|
|
259
|
+
)
|
|
260
|
+
print(json.dumps({"ok": True, "session": created, "cleanup": cleanup}, indent=2, sort_keys=True))
|
|
261
|
+
raise SystemExit(0)
|
|
262
|
+
|
|
263
|
+
if args.command == "monitors":
|
|
264
|
+
api_key = _required_api_key()
|
|
265
|
+
if args.monitor_command == "create":
|
|
266
|
+
payload = _request(
|
|
267
|
+
"POST",
|
|
268
|
+
f"{api_base}/monitors",
|
|
269
|
+
token=api_key,
|
|
270
|
+
json_body={
|
|
271
|
+
"name": args.name,
|
|
272
|
+
"url": args.url,
|
|
273
|
+
"schedule": {"interval_seconds": args.interval_seconds},
|
|
274
|
+
"formats": args.formats or ["markdown", "text", "metadata", "links"],
|
|
275
|
+
"diff_modes": args.diff_modes or ["text"],
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
_print_monitor_payload(payload, as_json=args.json)
|
|
279
|
+
elif args.monitor_command == "list":
|
|
280
|
+
suffix = "?include_deleted=true" if args.include_deleted else ""
|
|
281
|
+
payload = _request("GET", f"{api_base}/monitors{suffix}", token=api_key)
|
|
282
|
+
_print_monitor_payload(payload, as_json=args.json)
|
|
283
|
+
elif args.monitor_command == "get":
|
|
284
|
+
payload = _request("GET", f"{api_base}/monitors/{args.monitor_id}", token=api_key)
|
|
285
|
+
_print_monitor_payload(payload, as_json=args.json)
|
|
286
|
+
elif args.monitor_command == "pause":
|
|
287
|
+
payload = _request("POST", f"{api_base}/monitors/{args.monitor_id}/pause", token=api_key, json_body={})
|
|
288
|
+
_print_monitor_payload(payload, as_json=args.json)
|
|
289
|
+
elif args.monitor_command == "resume":
|
|
290
|
+
payload = _request(
|
|
291
|
+
"POST",
|
|
292
|
+
f"{api_base}/monitors/{args.monitor_id}/resume",
|
|
293
|
+
token=api_key,
|
|
294
|
+
json_body={"run_immediately": args.run_immediately},
|
|
295
|
+
)
|
|
296
|
+
_print_monitor_payload(payload, as_json=args.json)
|
|
297
|
+
elif args.monitor_command == "delete":
|
|
298
|
+
payload = _request("DELETE", f"{api_base}/monitors/{args.monitor_id}", token=api_key)
|
|
299
|
+
_print_monitor_payload(payload, as_json=args.json)
|
|
300
|
+
elif args.monitor_command == "run":
|
|
301
|
+
payload = _request("POST", f"{api_base}/monitors/{args.monitor_id}/run", token=api_key)
|
|
302
|
+
_print_monitor_payload(payload, as_json=args.json)
|
|
303
|
+
raise SystemExit(0)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _required_api_key() -> str:
|
|
307
|
+
api_key = os.getenv("CLAWIFI_API_KEY", "").strip()
|
|
308
|
+
if not api_key:
|
|
309
|
+
raise SystemExit("CLAWIFI_API_KEY is required. Run `clawifi signup --email you@example.com --password ...` first.")
|
|
310
|
+
return api_key
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _request(method: str, url: str, *, json_body: dict | None = None, token: str | None = None) -> dict:
|
|
314
|
+
body = json.dumps(json_body or {}, separators=(",", ":")).encode("utf-8") if json_body is not None else None
|
|
315
|
+
headers = {"Content-Type": "application/json", "User-Agent": "clawifi-cli/0.1.0 (+https://clawifi.qzz.io)"}
|
|
316
|
+
if token:
|
|
317
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
318
|
+
request = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
319
|
+
try:
|
|
320
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
321
|
+
return json.loads(response.read().decode("utf-8"))
|
|
322
|
+
except urllib.error.HTTPError as exc:
|
|
323
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
324
|
+
raise SystemExit(f"Clawifi API error {exc.code}: {detail}") from exc
|
|
325
|
+
except urllib.error.URLError as exc:
|
|
326
|
+
raise SystemExit(f"Clawifi API unreachable: {exc.reason}") from exc
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _print_monitor_payload(payload: dict, *, as_json: bool) -> None:
|
|
330
|
+
if as_json:
|
|
331
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
332
|
+
return
|
|
333
|
+
if "monitors" in payload:
|
|
334
|
+
print(f"Monitors: {payload.get('total', len(payload['monitors']))}")
|
|
335
|
+
for monitor in payload["monitors"]:
|
|
336
|
+
print(f"- {monitor.get('monitor_id')} {monitor.get('status')} {monitor.get('name')}")
|
|
337
|
+
return
|
|
338
|
+
label = payload.get("monitor_id") or payload.get("tick_id") or payload.get("status") or "ok"
|
|
339
|
+
print(f"Monitor result: {label}")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
if __name__ == "__main__":
|
|
343
|
+
run()
|
clawifi/client.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, AsyncIterator, Iterator
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from clawifi.errors import ClawifiAPIError, ClawifiTransportError
|
|
12
|
+
|
|
13
|
+
DEFAULT_API_BASE = "https://api.clawifi.qzz.io"
|
|
14
|
+
RETRYABLE_STATUS_CODES = {408, 425, 429, 500, 502, 503, 504}
|
|
15
|
+
TERMINAL_JOB_STATUSES = {"succeeded", "failed", "cancelled"}
|
|
16
|
+
TERMINAL_CRAWL_STATUSES = {"succeeded", "partial", "failed", "stopped", "interrupted"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ClawifiConfig:
|
|
21
|
+
api_key: str | None = None
|
|
22
|
+
api_base: str = DEFAULT_API_BASE
|
|
23
|
+
timeout: float = 30.0
|
|
24
|
+
max_retries: int = 2
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_env(cls) -> "ClawifiConfig":
|
|
28
|
+
timeout = float(os.getenv("CLAWIFI_TIMEOUT", "30"))
|
|
29
|
+
max_retries = int(os.getenv("CLAWIFI_MAX_RETRIES", "2"))
|
|
30
|
+
return cls(
|
|
31
|
+
api_key=os.getenv("CLAWIFI_API_KEY"),
|
|
32
|
+
api_base=os.getenv("CLAWIFI_API_BASE", DEFAULT_API_BASE).rstrip("/"),
|
|
33
|
+
timeout=timeout,
|
|
34
|
+
max_retries=max_retries,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str:
|
|
38
|
+
masked = "***" if self.api_key else None
|
|
39
|
+
return f"ClawifiConfig(api_key={masked!r}, api_base={self.api_base!r}, timeout={self.timeout!r}, max_retries={self.max_retries!r})"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _BaseClient:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
api_key: str | None = None,
|
|
47
|
+
api_base: str | None = None,
|
|
48
|
+
timeout: float | None = None,
|
|
49
|
+
max_retries: int | None = None,
|
|
50
|
+
config: ClawifiConfig | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
env_config = config or ClawifiConfig.from_env()
|
|
53
|
+
self.config = ClawifiConfig(
|
|
54
|
+
api_key=api_key if api_key is not None else env_config.api_key,
|
|
55
|
+
api_base=(api_base or env_config.api_base).rstrip("/"),
|
|
56
|
+
timeout=timeout if timeout is not None else env_config.timeout,
|
|
57
|
+
max_retries=max_retries if max_retries is not None else env_config.max_retries,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _headers(self) -> dict[str, str]:
|
|
61
|
+
headers = {"Accept": "application/json", "User-Agent": "clawifi/0.1.0"}
|
|
62
|
+
if self.config.api_key:
|
|
63
|
+
headers["Authorization"] = f"Bearer {self.config.api_key}"
|
|
64
|
+
return headers
|
|
65
|
+
|
|
66
|
+
def _url(self, path: str) -> str:
|
|
67
|
+
return f"{self.config.api_base}/{path.lstrip('/')}"
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _should_retry(method: str, idempotent: bool, status_code: int) -> bool:
|
|
71
|
+
return (method.upper() in {"GET", "HEAD"} or idempotent) and status_code in RETRYABLE_STATUS_CODES
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Clawifi(_BaseClient):
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
api_key: str | None = None,
|
|
79
|
+
api_base: str | None = None,
|
|
80
|
+
timeout: float | None = None,
|
|
81
|
+
max_retries: int | None = None,
|
|
82
|
+
config: ClawifiConfig | None = None,
|
|
83
|
+
transport: httpx.BaseTransport | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
super().__init__(api_key=api_key, api_base=api_base, timeout=timeout, max_retries=max_retries, config=config)
|
|
86
|
+
self._client = httpx.Client(timeout=self.config.timeout, transport=transport, headers=self._headers())
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def is_closed(self) -> bool:
|
|
90
|
+
return self._client.is_closed
|
|
91
|
+
|
|
92
|
+
def close(self) -> None:
|
|
93
|
+
self._client.close()
|
|
94
|
+
|
|
95
|
+
def __enter__(self) -> "Clawifi":
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
def __exit__(self, *args: object) -> None:
|
|
99
|
+
self.close()
|
|
100
|
+
|
|
101
|
+
def request(self, method: str, path: str, *, json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, idempotent: bool = False) -> dict[str, Any]:
|
|
102
|
+
attempts = self.config.max_retries + 1
|
|
103
|
+
for attempt in range(attempts):
|
|
104
|
+
try:
|
|
105
|
+
response = self._client.request(method, self._url(path), json=json, params=params)
|
|
106
|
+
except httpx.HTTPError as exc:
|
|
107
|
+
raise ClawifiTransportError(str(exc)) from exc
|
|
108
|
+
if response.status_code < 400:
|
|
109
|
+
return _decode_response(response)
|
|
110
|
+
payload = _decode_response(response)
|
|
111
|
+
if attempt < attempts - 1 and self._should_retry(method, idempotent, response.status_code):
|
|
112
|
+
continue
|
|
113
|
+
raise ClawifiAPIError.from_response(response.status_code, payload, request_id=response.headers.get("X-Clawifi-Request-ID"))
|
|
114
|
+
raise ClawifiTransportError("request retry loop exited unexpectedly")
|
|
115
|
+
|
|
116
|
+
def get(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
117
|
+
return self.request("GET", path, params=params)
|
|
118
|
+
|
|
119
|
+
def post(self, path: str, *, json: dict[str, Any] | None = None, idempotent: bool = False) -> dict[str, Any]:
|
|
120
|
+
return self.request("POST", path, json=json, idempotent=idempotent)
|
|
121
|
+
|
|
122
|
+
def fetch(self, url: str, **kwargs: Any) -> dict[str, Any]:
|
|
123
|
+
return self.post("/fetch", json={"url": url, **kwargs}, idempotent=True)
|
|
124
|
+
|
|
125
|
+
def scrape(self, url: str, **kwargs: Any) -> dict[str, Any]:
|
|
126
|
+
return self.post("/scrape", json={"url": url, **kwargs}, idempotent=True)
|
|
127
|
+
|
|
128
|
+
def schema_assist(self, **kwargs: Any) -> dict[str, Any]:
|
|
129
|
+
return self.post("/schema/assist", json=kwargs, idempotent=True)
|
|
130
|
+
|
|
131
|
+
def search_free(self, query: str, **kwargs: Any) -> dict[str, Any]:
|
|
132
|
+
return self.post("/search/free", json={"query": query, **kwargs}, idempotent=True)
|
|
133
|
+
|
|
134
|
+
def extract(self, url: str, **kwargs: Any) -> dict[str, Any]:
|
|
135
|
+
return self.post("/extract", json={"url": url, **kwargs}, idempotent=True)
|
|
136
|
+
|
|
137
|
+
def map(self, url: str, **kwargs: Any) -> dict[str, Any]:
|
|
138
|
+
return self.post("/map", json={"url": url, **kwargs}, idempotent=True)
|
|
139
|
+
|
|
140
|
+
def crawl(self, seed_url: str, **kwargs: Any) -> dict[str, Any]:
|
|
141
|
+
return self.post("/crawl", json={"seed_url": seed_url, **kwargs}, idempotent=True)
|
|
142
|
+
|
|
143
|
+
def create_job(self, operation: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
144
|
+
return self.post("/jobs", json={"operation": operation, "payload": payload})
|
|
145
|
+
|
|
146
|
+
def get_job(self, job_id: str) -> dict[str, Any]:
|
|
147
|
+
return self.get(f"/jobs/{job_id}")
|
|
148
|
+
|
|
149
|
+
def poll_job(self, job_id: str, *, interval_seconds: float = 0.5, timeout_seconds: float = 60.0) -> dict[str, Any]:
|
|
150
|
+
return _poll(lambda: self.get_job(job_id), status_key="status", terminal=TERMINAL_JOB_STATUSES, interval_seconds=interval_seconds, timeout_seconds=timeout_seconds)
|
|
151
|
+
|
|
152
|
+
def get_crawl(self, crawl_id: str) -> dict[str, Any]:
|
|
153
|
+
return self.get(f"/crawls/{crawl_id}")
|
|
154
|
+
|
|
155
|
+
def poll_crawl(self, crawl_id: str, *, interval_seconds: float = 0.5, timeout_seconds: float = 60.0) -> dict[str, Any]:
|
|
156
|
+
return _poll(lambda: self.get_crawl(crawl_id), status_key="status", terminal=TERMINAL_CRAWL_STATUSES, interval_seconds=interval_seconds, timeout_seconds=timeout_seconds)
|
|
157
|
+
|
|
158
|
+
def list_history(self, **params: Any) -> dict[str, Any]:
|
|
159
|
+
return self.get("/history", params=params)
|
|
160
|
+
|
|
161
|
+
def iter_history(self, **params: Any) -> Iterator[dict[str, Any]]:
|
|
162
|
+
yield from _iter_cursor(lambda cursor: self.list_history(**({**params, "cursor": cursor} if cursor else params)), "items")
|
|
163
|
+
|
|
164
|
+
def list_monitors(self, **params: Any) -> dict[str, Any]:
|
|
165
|
+
return self.get("/monitors", params=params)
|
|
166
|
+
|
|
167
|
+
def iter_monitors(self, **params: Any) -> Iterator[dict[str, Any]]:
|
|
168
|
+
yield from _iter_cursor(lambda cursor: self.list_monitors(**({**params, "cursor": cursor} if cursor else params)), "monitors")
|
|
169
|
+
|
|
170
|
+
def account(self) -> dict[str, Any]:
|
|
171
|
+
return self.get("/account/me")
|
|
172
|
+
|
|
173
|
+
def credits(self) -> dict[str, Any]:
|
|
174
|
+
return self.get("/usage")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class AsyncClawifi(_BaseClient):
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
api_key: str | None = None,
|
|
182
|
+
api_base: str | None = None,
|
|
183
|
+
timeout: float | None = None,
|
|
184
|
+
max_retries: int | None = None,
|
|
185
|
+
config: ClawifiConfig | None = None,
|
|
186
|
+
transport: httpx.AsyncBaseTransport | httpx.BaseTransport | None = None,
|
|
187
|
+
) -> None:
|
|
188
|
+
super().__init__(api_key=api_key, api_base=api_base, timeout=timeout, max_retries=max_retries, config=config)
|
|
189
|
+
self._client = httpx.AsyncClient(timeout=self.config.timeout, transport=transport, headers=self._headers())
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def is_closed(self) -> bool:
|
|
193
|
+
return self._client.is_closed
|
|
194
|
+
|
|
195
|
+
async def aclose(self) -> None:
|
|
196
|
+
await self._client.aclose()
|
|
197
|
+
|
|
198
|
+
async def __aenter__(self) -> "AsyncClawifi":
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
async def __aexit__(self, *args: object) -> None:
|
|
202
|
+
await self.aclose()
|
|
203
|
+
|
|
204
|
+
async def request(self, method: str, path: str, *, json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, idempotent: bool = False) -> dict[str, Any]:
|
|
205
|
+
attempts = self.config.max_retries + 1
|
|
206
|
+
for attempt in range(attempts):
|
|
207
|
+
try:
|
|
208
|
+
response = await self._client.request(method, self._url(path), json=json, params=params)
|
|
209
|
+
except httpx.HTTPError as exc:
|
|
210
|
+
raise ClawifiTransportError(str(exc)) from exc
|
|
211
|
+
if response.status_code < 400:
|
|
212
|
+
return _decode_response(response)
|
|
213
|
+
payload = _decode_response(response)
|
|
214
|
+
if attempt < attempts - 1 and self._should_retry(method, idempotent, response.status_code):
|
|
215
|
+
continue
|
|
216
|
+
raise ClawifiAPIError.from_response(response.status_code, payload, request_id=response.headers.get("X-Clawifi-Request-ID"))
|
|
217
|
+
raise ClawifiTransportError("request retry loop exited unexpectedly")
|
|
218
|
+
|
|
219
|
+
async def get(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
220
|
+
return await self.request("GET", path, params=params)
|
|
221
|
+
|
|
222
|
+
async def post(self, path: str, *, json: dict[str, Any] | None = None, idempotent: bool = False) -> dict[str, Any]:
|
|
223
|
+
return await self.request("POST", path, json=json, idempotent=idempotent)
|
|
224
|
+
|
|
225
|
+
async def fetch(self, url: str, **kwargs: Any) -> dict[str, Any]:
|
|
226
|
+
return await self.post("/fetch", json={"url": url, **kwargs}, idempotent=True)
|
|
227
|
+
|
|
228
|
+
async def scrape(self, url: str, **kwargs: Any) -> dict[str, Any]:
|
|
229
|
+
return await self.post("/scrape", json={"url": url, **kwargs}, idempotent=True)
|
|
230
|
+
|
|
231
|
+
async def schema_assist(self, **kwargs: Any) -> dict[str, Any]:
|
|
232
|
+
return await self.post("/schema/assist", json=kwargs, idempotent=True)
|
|
233
|
+
|
|
234
|
+
async def search_free(self, query: str, **kwargs: Any) -> dict[str, Any]:
|
|
235
|
+
return await self.post("/search/free", json={"query": query, **kwargs}, idempotent=True)
|
|
236
|
+
|
|
237
|
+
async def extract(self, url: str, **kwargs: Any) -> dict[str, Any]:
|
|
238
|
+
return await self.post("/extract", json={"url": url, **kwargs}, idempotent=True)
|
|
239
|
+
|
|
240
|
+
async def map(self, url: str, **kwargs: Any) -> dict[str, Any]:
|
|
241
|
+
return await self.post("/map", json={"url": url, **kwargs}, idempotent=True)
|
|
242
|
+
|
|
243
|
+
async def crawl(self, seed_url: str, **kwargs: Any) -> dict[str, Any]:
|
|
244
|
+
return await self.post("/crawl", json={"seed_url": seed_url, **kwargs}, idempotent=True)
|
|
245
|
+
|
|
246
|
+
async def create_job(self, operation: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
247
|
+
return await self.post("/jobs", json={"operation": operation, "payload": payload})
|
|
248
|
+
|
|
249
|
+
async def get_job(self, job_id: str) -> dict[str, Any]:
|
|
250
|
+
return await self.get(f"/jobs/{job_id}")
|
|
251
|
+
|
|
252
|
+
async def poll_job(self, job_id: str, *, interval_seconds: float = 0.5, timeout_seconds: float = 60.0) -> dict[str, Any]:
|
|
253
|
+
return await _apoll(lambda: self.get_job(job_id), status_key="status", terminal=TERMINAL_JOB_STATUSES, interval_seconds=interval_seconds, timeout_seconds=timeout_seconds)
|
|
254
|
+
|
|
255
|
+
async def get_crawl(self, crawl_id: str) -> dict[str, Any]:
|
|
256
|
+
return await self.get(f"/crawls/{crawl_id}")
|
|
257
|
+
|
|
258
|
+
async def poll_crawl(self, crawl_id: str, *, interval_seconds: float = 0.5, timeout_seconds: float = 60.0) -> dict[str, Any]:
|
|
259
|
+
return await _apoll(lambda: self.get_crawl(crawl_id), status_key="status", terminal=TERMINAL_CRAWL_STATUSES, interval_seconds=interval_seconds, timeout_seconds=timeout_seconds)
|
|
260
|
+
|
|
261
|
+
async def list_history(self, **params: Any) -> dict[str, Any]:
|
|
262
|
+
return await self.get("/history", params=params)
|
|
263
|
+
|
|
264
|
+
async def iter_history(self, **params: Any) -> AsyncIterator[dict[str, Any]]:
|
|
265
|
+
async for item in _aiter_cursor(lambda cursor: self.list_history(**({**params, "cursor": cursor} if cursor else params)), "items"):
|
|
266
|
+
yield item
|
|
267
|
+
|
|
268
|
+
async def list_monitors(self, **params: Any) -> dict[str, Any]:
|
|
269
|
+
return await self.get("/monitors", params=params)
|
|
270
|
+
|
|
271
|
+
async def iter_monitors(self, **params: Any) -> AsyncIterator[dict[str, Any]]:
|
|
272
|
+
async for item in _aiter_cursor(lambda cursor: self.list_monitors(**({**params, "cursor": cursor} if cursor else params)), "monitors"):
|
|
273
|
+
yield item
|
|
274
|
+
|
|
275
|
+
async def account(self) -> dict[str, Any]:
|
|
276
|
+
return await self.get("/account/me")
|
|
277
|
+
|
|
278
|
+
async def credits(self) -> dict[str, Any]:
|
|
279
|
+
return await self.get("/usage")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _decode_response(response: httpx.Response) -> dict[str, Any]:
|
|
283
|
+
try:
|
|
284
|
+
payload = response.json()
|
|
285
|
+
except ValueError as exc:
|
|
286
|
+
raise ClawifiTransportError("Clawifi response was not JSON") from exc
|
|
287
|
+
if not isinstance(payload, dict):
|
|
288
|
+
raise ClawifiTransportError("Clawifi response JSON must be an object")
|
|
289
|
+
return payload
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _poll(fetch_status, *, status_key: str, terminal: set[str], interval_seconds: float, timeout_seconds: float) -> dict[str, Any]:
|
|
293
|
+
deadline = time.monotonic() + timeout_seconds
|
|
294
|
+
while True:
|
|
295
|
+
payload = fetch_status()
|
|
296
|
+
if str(payload.get(status_key)) in terminal:
|
|
297
|
+
return payload
|
|
298
|
+
if time.monotonic() >= deadline:
|
|
299
|
+
raise TimeoutError("poll timed out")
|
|
300
|
+
time.sleep(interval_seconds)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
async def _apoll(fetch_status, *, status_key: str, terminal: set[str], interval_seconds: float, timeout_seconds: float) -> dict[str, Any]:
|
|
304
|
+
deadline = time.monotonic() + timeout_seconds
|
|
305
|
+
while True:
|
|
306
|
+
payload = await fetch_status()
|
|
307
|
+
if str(payload.get(status_key)) in terminal:
|
|
308
|
+
return payload
|
|
309
|
+
if time.monotonic() >= deadline:
|
|
310
|
+
raise TimeoutError("poll timed out")
|
|
311
|
+
await asyncio.sleep(interval_seconds)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _iter_cursor(fetch_page, item_key: str) -> Iterator[dict[str, Any]]:
|
|
315
|
+
cursor: str | None = None
|
|
316
|
+
seen_cursors: set[str] = set()
|
|
317
|
+
while True:
|
|
318
|
+
page = fetch_page(cursor)
|
|
319
|
+
yield from page.get(item_key, [])
|
|
320
|
+
cursor = page.get("next_cursor")
|
|
321
|
+
if not cursor or cursor in seen_cursors:
|
|
322
|
+
break
|
|
323
|
+
seen_cursors.add(cursor)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
async def _aiter_cursor(fetch_page, item_key: str) -> AsyncIterator[dict[str, Any]]:
|
|
327
|
+
cursor: str | None = None
|
|
328
|
+
seen_cursors: set[str] = set()
|
|
329
|
+
while True:
|
|
330
|
+
page = await fetch_page(cursor)
|
|
331
|
+
for item in page.get(item_key, []):
|
|
332
|
+
yield item
|
|
333
|
+
cursor = page.get("next_cursor")
|
|
334
|
+
if not cursor or cursor in seen_cursors:
|
|
335
|
+
break
|
|
336
|
+
seen_cursors.add(cursor)
|
clawifi/errors.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ClawifiError(Exception):
|
|
7
|
+
"""Base SDK error."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ClawifiTransportError(ClawifiError):
|
|
11
|
+
"""Raised when the HTTP transport fails before Clawifi returns an error payload."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ClawifiAPIError(ClawifiError):
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
message: str,
|
|
18
|
+
*,
|
|
19
|
+
status_code: int | None = None,
|
|
20
|
+
code: str | None = None,
|
|
21
|
+
request_id: str | None = None,
|
|
22
|
+
details: dict[str, Any] | None = None,
|
|
23
|
+
response: dict[str, Any] | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
self.code = code
|
|
28
|
+
self.request_id = request_id
|
|
29
|
+
self.details = details or {}
|
|
30
|
+
self.response = response or {}
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_response(cls, status_code: int, payload: dict[str, Any], *, request_id: str | None = None) -> "ClawifiAPIError":
|
|
34
|
+
error = payload.get("error") if isinstance(payload, dict) else None
|
|
35
|
+
if not isinstance(error, dict):
|
|
36
|
+
return cls("Clawifi API request failed", status_code=status_code, request_id=request_id, response=payload)
|
|
37
|
+
details = error.get("details") if isinstance(error.get("details"), dict) else {}
|
|
38
|
+
error_request_id = error.get("request_id") or details.get("request_id") or request_id
|
|
39
|
+
return cls(
|
|
40
|
+
str(error.get("message") or "Clawifi API request failed"),
|
|
41
|
+
status_code=status_code,
|
|
42
|
+
code=error.get("code"),
|
|
43
|
+
request_id=str(error_request_id) if error_request_id else None,
|
|
44
|
+
details=details,
|
|
45
|
+
response=payload,
|
|
46
|
+
)
|
clawifi/mcp_server.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from clawifi.client import Clawifi
|
|
9
|
+
|
|
10
|
+
mcp = FastMCP("clawifi")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _client() -> Clawifi:
|
|
14
|
+
return Clawifi()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@mcp.tool()
|
|
18
|
+
def fetch(url: str, timeout_seconds: int = 15) -> dict[str, Any]:
|
|
19
|
+
"""Fetch a single URL and return its extracted content."""
|
|
20
|
+
with _client() as client:
|
|
21
|
+
return client.fetch(url, timeout_seconds=timeout_seconds)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp.tool()
|
|
25
|
+
def scrape(url: str, **kwargs: Any) -> dict[str, Any]:
|
|
26
|
+
"""Scrape a URL with source-aware routing and return structured content."""
|
|
27
|
+
with _client() as client:
|
|
28
|
+
return client.scrape(url, **kwargs)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@mcp.tool()
|
|
32
|
+
def search_free(query: str) -> dict[str, Any]:
|
|
33
|
+
"""Run a free-tier web search and return results."""
|
|
34
|
+
with _client() as client:
|
|
35
|
+
return client.search_free(query)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@mcp.tool()
|
|
39
|
+
def extract(url: str, **kwargs: Any) -> dict[str, Any]:
|
|
40
|
+
"""Extract structured data from a URL."""
|
|
41
|
+
with _client() as client:
|
|
42
|
+
return client.extract(url, **kwargs)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp.tool()
|
|
46
|
+
def map_site(url: str, **kwargs: Any) -> dict[str, Any]:
|
|
47
|
+
"""Map a site's discoverable URLs starting from a seed URL."""
|
|
48
|
+
with _client() as client:
|
|
49
|
+
return client.map(url, **kwargs)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@mcp.tool()
|
|
53
|
+
def crawl(seed_url: str, **kwargs: Any) -> dict[str, Any]:
|
|
54
|
+
"""Start an asynchronous crawl from a seed URL. Returns a crawl_id to poll."""
|
|
55
|
+
with _client() as client:
|
|
56
|
+
return client.crawl(seed_url, **kwargs)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
def get_crawl(crawl_id: str) -> dict[str, Any]:
|
|
61
|
+
"""Get the current status and results of a crawl."""
|
|
62
|
+
with _client() as client:
|
|
63
|
+
return client.get_crawl(crawl_id)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@mcp.tool()
|
|
67
|
+
def create_job(operation: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
68
|
+
"""Create an asynchronous job for a billable operation. Returns a job_id to poll."""
|
|
69
|
+
with _client() as client:
|
|
70
|
+
return client.create_job(operation, payload)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@mcp.tool()
|
|
74
|
+
def get_job(job_id: str) -> dict[str, Any]:
|
|
75
|
+
"""Get the current status and results of a job."""
|
|
76
|
+
with _client() as client:
|
|
77
|
+
return client.get_job(job_id)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
def account() -> dict[str, Any]:
|
|
82
|
+
"""Get the authenticated account's profile and plan."""
|
|
83
|
+
with _client() as client:
|
|
84
|
+
return client.account()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@mcp.tool()
|
|
88
|
+
def credits() -> dict[str, Any]:
|
|
89
|
+
"""Get the authenticated account's credit usage and remaining balance."""
|
|
90
|
+
with _client() as client:
|
|
91
|
+
return client.credits()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@mcp.tool()
|
|
95
|
+
def list_history(**params: Any) -> dict[str, Any]:
|
|
96
|
+
"""List past requests for the authenticated account."""
|
|
97
|
+
with _client() as client:
|
|
98
|
+
return client.list_history(**params)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@mcp.tool()
|
|
102
|
+
def list_monitors(**params: Any) -> dict[str, Any]:
|
|
103
|
+
"""List configured Evidence Monitor checks for the authenticated account."""
|
|
104
|
+
with _client() as client:
|
|
105
|
+
return client.list_monitors(**params)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run() -> None:
|
|
109
|
+
if not os.getenv("CLAWIFI_API_KEY"):
|
|
110
|
+
raise SystemExit(
|
|
111
|
+
"CLAWIFI_API_KEY is required. Get one at https://clawifi.qzz.io/signup, "
|
|
112
|
+
"then export CLAWIFI_API_KEY=... before starting this MCP server."
|
|
113
|
+
)
|
|
114
|
+
mcp.run()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
run()
|
clawifi/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
clawifi/testing.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fixture_root(start: Path | None = None) -> Path:
|
|
11
|
+
current = (start or Path(__file__).resolve()).parent
|
|
12
|
+
for candidate in [current, *current.parents]:
|
|
13
|
+
manifest = candidate / "tests" / "fixtures" / "sdk" / "manifest.json"
|
|
14
|
+
if manifest.exists():
|
|
15
|
+
return manifest.parent
|
|
16
|
+
raise FileNotFoundError("could not locate tests/fixtures/sdk/manifest.json")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_fixture_manifest(root: Path | None = None) -> dict[str, Any]:
|
|
20
|
+
manifest_path = (root or fixture_root()) / "manifest.json"
|
|
21
|
+
return _load_json(manifest_path)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fixture_transport(*, errors: set[str] | None = None, root: Path | None = None) -> httpx.MockTransport:
|
|
25
|
+
fixture_dir = root or fixture_root()
|
|
26
|
+
manifest = load_fixture_manifest(fixture_dir)
|
|
27
|
+
routes = _routes_from_manifest(manifest)
|
|
28
|
+
error_routes = errors or set()
|
|
29
|
+
|
|
30
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
31
|
+
route_key = f"{request.method.upper()} {request.url.path}"
|
|
32
|
+
metadata = routes.get(route_key)
|
|
33
|
+
if metadata is None and route_key.startswith("GET /jobs/"):
|
|
34
|
+
metadata = routes["POST /jobs"]
|
|
35
|
+
if metadata is None and route_key.startswith("GET /crawls/"):
|
|
36
|
+
metadata = routes["POST /crawl"]
|
|
37
|
+
if metadata is None:
|
|
38
|
+
return httpx.Response(404, json=_not_found_payload(route_key), headers={"X-Clawifi-Request-ID": "req_sdk_fixture"})
|
|
39
|
+
|
|
40
|
+
fixture_name = metadata["error_fixture"] if route_key in error_routes else metadata["success_fixture"]
|
|
41
|
+
status_code = 422 if route_key in error_routes else 200
|
|
42
|
+
return httpx.Response(
|
|
43
|
+
status_code,
|
|
44
|
+
json=_load_json(fixture_dir / fixture_name),
|
|
45
|
+
headers={"X-Clawifi-Request-ID": "req_sdk_fixture"},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return httpx.MockTransport(handler)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _routes_from_manifest(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
52
|
+
operations = manifest.get("operations", {})
|
|
53
|
+
return {f"{metadata['method'].upper()} {metadata['path']}": metadata for metadata in operations.values()}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load_json(path: Path) -> dict[str, Any]:
|
|
57
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
58
|
+
if not isinstance(payload, dict):
|
|
59
|
+
raise ValueError(f"fixture must be a JSON object: {path}")
|
|
60
|
+
return payload
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _not_found_payload(route_key: str) -> dict[str, Any]:
|
|
64
|
+
return {
|
|
65
|
+
"error": {
|
|
66
|
+
"code": "fixture_not_found",
|
|
67
|
+
"message": f"No SDK fixture exists for {route_key}",
|
|
68
|
+
"details": {"request_id": "req_sdk_fixture", "retryable": False},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clawifi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK, CLI, and MCP server for Clawifi, the agent-native internet gateway.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://clawifi.qzz.io
|
|
7
|
+
Project-URL: Documentation, https://clawifi.qzz.io/docs
|
|
8
|
+
Project-URL: Repository, https://github.com/atakanelik34/clawifi-mcp
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: httpx>=0.27.2
|
|
17
|
+
Requires-Dist: mcp>=1.2.0
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=8.3.3; extra == "test"
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == "test"
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# clawifi
|
|
24
|
+
|
|
25
|
+
<!-- mcp-name: io.github.atakanelik34/clawifi-mcp -->
|
|
26
|
+
|
|
27
|
+
Official Python SDK, CLI, and MCP server for [Clawifi](https://clawifi.qzz.io), the agent-native internet gateway.
|
|
28
|
+
|
|
29
|
+
This package is a thin client over the hosted Clawifi API. It does not include the Clawifi server, browser runtime, or Playwright — those run on Clawifi's infrastructure.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install clawifi
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Get an API key
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
clawifi signup --email you@example.com --password "a strong password"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
This creates an account, grants 1,000 free credits, and prints an API key. Export it:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
export CLAWIFI_API_KEY="clawifi_live_..."
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Quicktest
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
clawifi quicktest --url https://example.com
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Use as an MCP server
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
clawifi mcp config
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
prints a ready-to-paste MCP client config. The MCP server itself runs as:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
clawifi-mcp
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
reading `CLAWIFI_API_KEY` from the environment.
|
|
68
|
+
|
|
69
|
+
## Use as a Python SDK
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from clawifi import Clawifi
|
|
73
|
+
|
|
74
|
+
client = Clawifi() # reads CLAWIFI_API_KEY from the environment
|
|
75
|
+
page = client.fetch("https://example.com")
|
|
76
|
+
print(page["success"])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
An async client is also available as `clawifi.AsyncClawifi`.
|
|
80
|
+
|
|
81
|
+
## Documentation
|
|
82
|
+
|
|
83
|
+
See [clawifi.qzz.io/docs](https://clawifi.qzz.io/docs) for the full API reference, MCP tool list, and CLI command reference.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
clawifi/__init__.py,sha256=KtRFoqeDwHlolHjqGOZFQ7HDbXT0EntsNX2CZ5sbFvo,323
|
|
2
|
+
clawifi/cli.py,sha256=OVamRM8xlv74Dq3o9HFEn1VMh_du1OZKXxd1KiDIXMk,16110
|
|
3
|
+
clawifi/client.py,sha256=JC-Mw1PdqijLLQZzWcX8Dn8_DGd1j8dKui1-s9rkxTo,14885
|
|
4
|
+
clawifi/errors.py,sha256=7r9l8jaXnmLhl-rVftmMDYuQ1_3IkixQuLzuXSfUScg,1659
|
|
5
|
+
clawifi/mcp_server.py,sha256=v8U9ybLLzM-zLt0hhm5egVV729QT65Hpo_tUCuJt0D8,3233
|
|
6
|
+
clawifi/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
7
|
+
clawifi/testing.py,sha256=gApEtg1hxsBhG_o0PfCcBGAzxt6qGm1UST2MPi1GI0M,2671
|
|
8
|
+
clawifi-0.1.0.dist-info/licenses/LICENSE,sha256=SSB7B93_fwJ_sqzycDbpfLT6gR3B4RMbZSfgESGe8EU,1067
|
|
9
|
+
clawifi-0.1.0.dist-info/METADATA,sha256=0uT4zumL5fhU_S4-urroq9zB8JUoZYQs-WLBnQU_c6E,2199
|
|
10
|
+
clawifi-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
11
|
+
clawifi-0.1.0.dist-info/entry_points.txt,sha256=4KexgBzTL7fkNLviv38_ffSZlYB1nNJP55D6qx10sSA,81
|
|
12
|
+
clawifi-0.1.0.dist-info/top_level.txt,sha256=14GNHtu-AfvixW5yZpzkhClCs4X-Y0L925aFr8CBDuw,8
|
|
13
|
+
clawifi-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ReARC Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
clawifi
|