@agentoctopus/cli 0.4.3 → 0.4.4
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.
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +35 -0
- package/dist/config.js.map +1 -0
- package/dist/index.js +87 -3
- package/dist/index.js.map +1 -1
- package/dist/onboard.d.ts.map +1 -1
- package/dist/onboard.js +122 -15
- package/dist/onboard.js.map +1 -1
- package/dist/skill-create.d.ts +3 -0
- package/dist/skill-create.d.ts.map +1 -0
- package/dist/skill-create.js +220 -0
- package/dist/skill-create.js.map +1 -0
- package/package.json +5 -4
- package/skills/ip-lookup/SKILL.md +25 -0
- package/skills/ip-lookup/scripts/invoke.js +56 -0
- package/skills/translation/SKILL.md +23 -0
- package/skills/translation/scripts/invoke.js +63 -0
- package/skills/weather/SKILL.md +23 -0
- package/skills/weather/scripts/invoke.js +58 -0
- package/skills/x-search/SKILL.md +26 -0
- package/skills/x-search/scripts/invoke.js +57 -0
- package/skills/x-search/scripts/search.py +282 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Search X (Twitter) posts using the xAI Grok API."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
import signal
|
|
10
|
+
from urllib.request import Request, HTTPRedirectHandler, build_opener
|
|
11
|
+
from urllib.error import URLError, HTTPError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
15
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
16
|
+
raise HTTPError(newurl, code, f"Redirect to {newurl} blocked (auth safety)", headers, fp)
|
|
17
|
+
|
|
18
|
+
API_URL = "https://api.x.ai/v1/responses"
|
|
19
|
+
MODEL = "grok-4.20-reasoning"
|
|
20
|
+
TIMEOUT_S = 120
|
|
21
|
+
MAX_HANDLES = 10
|
|
22
|
+
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
23
|
+
HANDLE_RE = re.compile(r"^[a-zA-Z0-9_]{1,15}$")
|
|
24
|
+
FLAGS_WITH_VALUES = {"--handles", "--exclude", "--from", "--to"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def die(msg: str) -> None:
|
|
28
|
+
print(f"Error: {msg}", file=sys.stderr)
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_args(argv: list[str]) -> dict:
|
|
33
|
+
args = argv[1:]
|
|
34
|
+
options: dict = {
|
|
35
|
+
"handles": None,
|
|
36
|
+
"exclude": None,
|
|
37
|
+
"from_date": None,
|
|
38
|
+
"to_date": None,
|
|
39
|
+
"images": False,
|
|
40
|
+
"video": False,
|
|
41
|
+
"query": [],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if not args or args[0] in ("-h", "--help"):
|
|
45
|
+
print(
|
|
46
|
+
'Usage: python3 search.py [--handles h1,h2] [--exclude h1,h2] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--images] [--video] "<query>"',
|
|
47
|
+
file=sys.stderr,
|
|
48
|
+
)
|
|
49
|
+
sys.exit(0 if args else 1)
|
|
50
|
+
|
|
51
|
+
# Expand --flag=value into --flag value
|
|
52
|
+
expanded: list[str] = []
|
|
53
|
+
for a in args:
|
|
54
|
+
if "=" in a and a.split("=", 1)[0] in FLAGS_WITH_VALUES:
|
|
55
|
+
expanded.extend(a.split("=", 1))
|
|
56
|
+
else:
|
|
57
|
+
expanded.append(a)
|
|
58
|
+
|
|
59
|
+
i = 0
|
|
60
|
+
flags_done = False
|
|
61
|
+
while i < len(expanded):
|
|
62
|
+
arg = expanded[i]
|
|
63
|
+
|
|
64
|
+
if arg == "--" and not flags_done:
|
|
65
|
+
flags_done = True
|
|
66
|
+
i += 1
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
if not flags_done and arg in FLAGS_WITH_VALUES and (i + 1 >= len(expanded) or expanded[i + 1].startswith("--")):
|
|
70
|
+
die(f"{arg} requires a value.")
|
|
71
|
+
|
|
72
|
+
if not flags_done and arg == "--handles":
|
|
73
|
+
i += 1
|
|
74
|
+
options["handles"] = [h.strip().lstrip("@") for h in expanded[i].split(",") if h.strip().lstrip("@")]
|
|
75
|
+
elif not flags_done and arg == "--exclude":
|
|
76
|
+
i += 1
|
|
77
|
+
options["exclude"] = [h.strip().lstrip("@") for h in expanded[i].split(",") if h.strip().lstrip("@")]
|
|
78
|
+
elif not flags_done and arg == "--from":
|
|
79
|
+
i += 1
|
|
80
|
+
options["from_date"] = expanded[i]
|
|
81
|
+
elif not flags_done and arg == "--to":
|
|
82
|
+
i += 1
|
|
83
|
+
options["to_date"] = expanded[i]
|
|
84
|
+
elif not flags_done and arg == "--images":
|
|
85
|
+
options["images"] = True
|
|
86
|
+
elif not flags_done and arg == "--video":
|
|
87
|
+
options["video"] = True
|
|
88
|
+
elif not flags_done and arg.startswith("--"):
|
|
89
|
+
die(f'Unknown flag "{arg}". Use --help for usage.')
|
|
90
|
+
else:
|
|
91
|
+
options["query"].append(arg)
|
|
92
|
+
|
|
93
|
+
i += 1
|
|
94
|
+
|
|
95
|
+
return options
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def validate(options: dict) -> None:
|
|
99
|
+
handles = options["handles"]
|
|
100
|
+
exclude = options["exclude"]
|
|
101
|
+
from_date = options["from_date"]
|
|
102
|
+
to_date = options["to_date"]
|
|
103
|
+
|
|
104
|
+
if handles is not None and exclude is not None:
|
|
105
|
+
die("--handles and --exclude cannot be used together.")
|
|
106
|
+
|
|
107
|
+
if handles is not None and len(handles) == 0:
|
|
108
|
+
die("--handles value is empty.")
|
|
109
|
+
|
|
110
|
+
if exclude is not None and len(exclude) == 0:
|
|
111
|
+
die("--exclude value is empty.")
|
|
112
|
+
|
|
113
|
+
for h in (handles or []) + (exclude or []):
|
|
114
|
+
if not HANDLE_RE.match(h):
|
|
115
|
+
die(f'"{h}" is not a valid X handle (letters, numbers, underscores, max 15 chars).')
|
|
116
|
+
|
|
117
|
+
if handles and len(handles) > MAX_HANDLES:
|
|
118
|
+
die(f"--handles accepts at most {MAX_HANDLES} handles, got {len(handles)}.")
|
|
119
|
+
|
|
120
|
+
if exclude and len(exclude) > MAX_HANDLES:
|
|
121
|
+
die(f"--exclude accepts at most {MAX_HANDLES} handles, got {len(exclude)}.")
|
|
122
|
+
|
|
123
|
+
if from_date and not DATE_RE.match(from_date):
|
|
124
|
+
die(f'--from must be YYYY-MM-DD format, got "{from_date}".')
|
|
125
|
+
|
|
126
|
+
if to_date and not DATE_RE.match(to_date):
|
|
127
|
+
die(f'--to must be YYYY-MM-DD format, got "{to_date}".')
|
|
128
|
+
|
|
129
|
+
if from_date:
|
|
130
|
+
try:
|
|
131
|
+
datetime.strptime(from_date, "%Y-%m-%d")
|
|
132
|
+
except ValueError:
|
|
133
|
+
die(f'--from date "{from_date}" is not a valid date.')
|
|
134
|
+
|
|
135
|
+
if to_date:
|
|
136
|
+
try:
|
|
137
|
+
datetime.strptime(to_date, "%Y-%m-%d")
|
|
138
|
+
except ValueError:
|
|
139
|
+
die(f'--to date "{to_date}" is not a valid date.')
|
|
140
|
+
|
|
141
|
+
if from_date and to_date and from_date > to_date:
|
|
142
|
+
die(f"--from date ({from_date}) must be before --to date ({to_date}).")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_tool_config(options: dict) -> dict:
|
|
146
|
+
tool: dict = {"type": "x_search"}
|
|
147
|
+
|
|
148
|
+
if options["handles"]:
|
|
149
|
+
tool["allowed_x_handles"] = options["handles"]
|
|
150
|
+
if options["exclude"]:
|
|
151
|
+
tool["excluded_x_handles"] = options["exclude"]
|
|
152
|
+
if options["from_date"]:
|
|
153
|
+
tool["from_date"] = options["from_date"]
|
|
154
|
+
if options["to_date"]:
|
|
155
|
+
tool["to_date"] = options["to_date"]
|
|
156
|
+
if options["images"]:
|
|
157
|
+
tool["enable_image_understanding"] = True
|
|
158
|
+
if options["video"]:
|
|
159
|
+
tool["enable_video_understanding"] = True
|
|
160
|
+
|
|
161
|
+
return tool
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _safe_get(obj: object, key: str, default: object = None) -> object:
|
|
165
|
+
"""Safely get a key from a dict-like object, returning default if obj is not a dict."""
|
|
166
|
+
if isinstance(obj, dict):
|
|
167
|
+
return obj.get(key, default)
|
|
168
|
+
return default
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def format_response(data: dict, query: str) -> dict:
|
|
172
|
+
outputs = _safe_get(data, "output", [])
|
|
173
|
+
if not isinstance(outputs, list):
|
|
174
|
+
outputs = []
|
|
175
|
+
usage = _safe_get(data, "usage", {})
|
|
176
|
+
if not isinstance(usage, dict):
|
|
177
|
+
usage = {}
|
|
178
|
+
tool_details = _safe_get(usage, "server_side_tool_usage_details", {})
|
|
179
|
+
if not isinstance(tool_details, dict):
|
|
180
|
+
tool_details = {}
|
|
181
|
+
|
|
182
|
+
message = next((o for o in outputs if isinstance(o, dict) and o.get("type") == "message"), None)
|
|
183
|
+
content_blocks = _safe_get(message, "content", [])
|
|
184
|
+
if not isinstance(content_blocks, list):
|
|
185
|
+
content_blocks = []
|
|
186
|
+
|
|
187
|
+
text = "\n\n".join(
|
|
188
|
+
c.get("text", "")
|
|
189
|
+
for c in content_blocks
|
|
190
|
+
if isinstance(c, dict) and c.get("text")
|
|
191
|
+
)
|
|
192
|
+
annotations = [
|
|
193
|
+
a
|
|
194
|
+
for c in content_blocks if isinstance(c, dict)
|
|
195
|
+
for a in (c.get("annotations") or []) if isinstance(a, dict)
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
citations = [
|
|
199
|
+
{"text": a.get("title", ""), "url": a["url"]}
|
|
200
|
+
for a in annotations
|
|
201
|
+
if a.get("type") == "url_citation" and a.get("url")
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
status = _safe_get(data, "status", "unknown") or "unknown"
|
|
205
|
+
if status not in ("completed", "unknown"):
|
|
206
|
+
error = _safe_get(data, "error", {})
|
|
207
|
+
error_msg = error.get("message", "") if isinstance(error, dict) else str(error)
|
|
208
|
+
text = f"Search {status}" + (f": {error_msg}" if error_msg else "") + ("\n\n" + text if text else "")
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
"status": status,
|
|
212
|
+
"query": query,
|
|
213
|
+
"text": text,
|
|
214
|
+
"citations": citations,
|
|
215
|
+
"searches": tool_details.get("x_search_calls", 0),
|
|
216
|
+
"tokens": {
|
|
217
|
+
"input": usage.get("input_tokens", 0),
|
|
218
|
+
"output": usage.get("output_tokens", 0),
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def search(options: dict) -> None:
|
|
224
|
+
api_key = os.environ.get("XAI_API_KEY", "").strip()
|
|
225
|
+
if not api_key:
|
|
226
|
+
die("XAI_API_KEY environment variable is not set.")
|
|
227
|
+
|
|
228
|
+
query = " ".join(w for w in options["query"] if w).strip()
|
|
229
|
+
if not query:
|
|
230
|
+
print("Error: No search query provided.", file=sys.stderr)
|
|
231
|
+
print(
|
|
232
|
+
'Usage: python3 search.py [--handles h1,h2] [--exclude h1,h2] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--images] [--video] "<query>"',
|
|
233
|
+
file=sys.stderr,
|
|
234
|
+
)
|
|
235
|
+
sys.exit(1)
|
|
236
|
+
|
|
237
|
+
body = json.dumps({
|
|
238
|
+
"model": MODEL,
|
|
239
|
+
"input": [{"role": "user", "content": query}],
|
|
240
|
+
"tools": [build_tool_config(options)],
|
|
241
|
+
}).encode()
|
|
242
|
+
|
|
243
|
+
req = Request(
|
|
244
|
+
API_URL,
|
|
245
|
+
data=body,
|
|
246
|
+
headers={
|
|
247
|
+
"Content-Type": "application/json",
|
|
248
|
+
"Authorization": f"Bearer {api_key}",
|
|
249
|
+
"User-Agent": "OpenClaw/x-search",
|
|
250
|
+
},
|
|
251
|
+
method="POST",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
opener = build_opener(_NoRedirect)
|
|
255
|
+
try:
|
|
256
|
+
with opener.open(req, timeout=TIMEOUT_S) as resp:
|
|
257
|
+
data = json.loads(resp.read())
|
|
258
|
+
except HTTPError as e:
|
|
259
|
+
try:
|
|
260
|
+
error_body = e.read().decode("utf-8", errors="replace") if e.fp else ""
|
|
261
|
+
except Exception:
|
|
262
|
+
error_body = ""
|
|
263
|
+
die(f"API error ({e.code}): {error_body}")
|
|
264
|
+
except URLError as e:
|
|
265
|
+
die(f"Network request failed: {e.reason}")
|
|
266
|
+
except TimeoutError:
|
|
267
|
+
die(f"Request timed out after {TIMEOUT_S}s.")
|
|
268
|
+
except json.JSONDecodeError:
|
|
269
|
+
die("Failed to parse API response as JSON.")
|
|
270
|
+
except OSError as e:
|
|
271
|
+
die(f"Connection error: {e}")
|
|
272
|
+
|
|
273
|
+
output = format_response(data, query)
|
|
274
|
+
print(json.dumps(output, indent=2, ensure_ascii=False))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == "__main__":
|
|
278
|
+
signal.signal(signal.SIGINT, lambda *_: (print("\nInterrupted.", file=sys.stderr), sys.exit(130)))
|
|
279
|
+
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
|
280
|
+
options = parse_args(sys.argv)
|
|
281
|
+
validate(options)
|
|
282
|
+
search(options)
|