@dhf-hermes/grix 0.1.0

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.

Potentially problematic release.


This version of @dhf-hermes/grix might be problematic. Click here for more details.

Files changed (51) hide show
  1. package/.gitignore +6 -0
  2. package/LICENSE +21 -0
  3. package/README.md +98 -0
  4. package/bin/grix-hermes.mjs +93 -0
  5. package/grix-admin/SKILL.md +109 -0
  6. package/grix-admin/agents/openai.yaml +7 -0
  7. package/grix-admin/scripts/admin.mjs +12 -0
  8. package/grix-admin/scripts/bind_from_json.py +118 -0
  9. package/grix-admin/scripts/bind_local.py +226 -0
  10. package/grix-egg/SKILL.md +73 -0
  11. package/grix-egg/agents/openai.yaml +7 -0
  12. package/grix-egg/references/acceptance-checklist.md +10 -0
  13. package/grix-egg/scripts/card-link.mjs +12 -0
  14. package/grix-egg/scripts/validate_install_context.mjs +74 -0
  15. package/grix-group/SKILL.md +42 -0
  16. package/grix-group/agents/openai.yaml +7 -0
  17. package/grix-group/scripts/group.mjs +12 -0
  18. package/grix-query/SKILL.md +53 -0
  19. package/grix-query/agents/openai.yaml +7 -0
  20. package/grix-query/scripts/query.mjs +12 -0
  21. package/grix-register/SKILL.md +68 -0
  22. package/grix-register/agents/openai.yaml +7 -0
  23. package/grix-register/references/handoff-contract.md +21 -0
  24. package/grix-register/scripts/create_api_agent_and_bind.py +105 -0
  25. package/grix-register/scripts/grix_auth.py +487 -0
  26. package/grix-update/SKILL.md +50 -0
  27. package/grix-update/agents/openai.yaml +7 -0
  28. package/grix-update/references/cron-setup.md +11 -0
  29. package/grix-update/scripts/grix_update.py +99 -0
  30. package/lib/manifest.mjs +68 -0
  31. package/message-send/SKILL.md +71 -0
  32. package/message-send/agents/openai.yaml +7 -0
  33. package/message-send/scripts/card-link.mjs +40 -0
  34. package/message-send/scripts/send.mjs +12 -0
  35. package/message-unsend/SKILL.md +39 -0
  36. package/message-unsend/agents/openai.yaml +7 -0
  37. package/message-unsend/scripts/unsend.mjs +12 -0
  38. package/openclaw-memory-setup/SKILL.md +38 -0
  39. package/openclaw-memory-setup/agents/openai.yaml +7 -0
  40. package/openclaw-memory-setup/scripts/bench_ollama_embeddings.py +257 -0
  41. package/openclaw-memory-setup/scripts/set_openclaw_memory_model.py +240 -0
  42. package/openclaw-memory-setup/scripts/survey_host_readiness.py +379 -0
  43. package/package.json +51 -0
  44. package/shared/cli/actions.mjs +339 -0
  45. package/shared/cli/aibot-client.mjs +274 -0
  46. package/shared/cli/card-links.mjs +90 -0
  47. package/shared/cli/config.mjs +141 -0
  48. package/shared/cli/grix-hermes.mjs +87 -0
  49. package/shared/cli/targets.mjs +119 -0
  50. package/shared/references/grix-card-links.md +27 -0
  51. package/shared/references/hermes-grix-config.md +30 -0
@@ -0,0 +1,487 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import base64
4
+ import json
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ import uuid
12
+
13
+
14
+ DEFAULT_BASE_URL = "https://grix.dhf.pub"
15
+ DEFAULT_TIMEOUT_SECONDS = 15
16
+
17
+
18
+ def resolve_default_base_url() -> str:
19
+ return (os.environ.get("GRIX_WEB_BASE_URL", "") or "").strip() or DEFAULT_BASE_URL
20
+
21
+
22
+ def derive_portal_url(raw_base_url: str) -> str:
23
+ base = (raw_base_url or "").strip() or resolve_default_base_url()
24
+ parsed = urllib.parse.urlparse(base)
25
+ if not parsed.scheme or not parsed.netloc:
26
+ raise ValueError(f"Invalid base URL: {base}")
27
+
28
+ path = parsed.path.rstrip("/")
29
+ if path.endswith("/v1"):
30
+ path = path[: -len("/v1")]
31
+
32
+ normalized = parsed._replace(path=path or "/", params="", query="", fragment="")
33
+ return urllib.parse.urlunparse(normalized).rstrip("/") + "/"
34
+
35
+
36
+ class GrixAuthError(RuntimeError):
37
+ def __init__(self, message, status=0, code=-1, payload=None):
38
+ super().__init__(message)
39
+ self.status = status
40
+ self.code = code
41
+ self.payload = payload
42
+
43
+
44
+ def normalize_base_url(raw_base_url: str) -> str:
45
+ base = (raw_base_url or "").strip() or resolve_default_base_url()
46
+ parsed = urllib.parse.urlparse(base)
47
+ if not parsed.scheme or not parsed.netloc:
48
+ raise ValueError(f"Invalid base URL: {base}")
49
+
50
+ path = parsed.path.rstrip("/")
51
+ if not path:
52
+ path = "/v1"
53
+ elif not path.endswith("/v1"):
54
+ path = f"{path}/v1"
55
+
56
+ normalized = parsed._replace(path=path, params="", query="", fragment="")
57
+ return urllib.parse.urlunparse(normalized).rstrip("/")
58
+
59
+
60
+ def request_json(method: str, path: str, base_url: str, body=None, headers=None):
61
+ api_base_url = normalize_base_url(base_url)
62
+ url = f"{api_base_url}{path if path.startswith('/') else '/' + path}"
63
+ data = None
64
+ final_headers = dict(headers or {})
65
+ if body is not None:
66
+ data = json.dumps(body).encode("utf-8")
67
+ final_headers["Content-Type"] = "application/json"
68
+
69
+ req = urllib.request.Request(url=url, data=data, headers=final_headers, method=method)
70
+ try:
71
+ with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT_SECONDS) as resp:
72
+ status = getattr(resp, "status", 200)
73
+ raw = resp.read().decode("utf-8")
74
+ except urllib.error.HTTPError as exc:
75
+ status = exc.code
76
+ raw = exc.read().decode("utf-8", errors="replace")
77
+ except urllib.error.URLError as exc:
78
+ raise GrixAuthError(f"network error: {exc.reason}") from exc
79
+
80
+ try:
81
+ payload = json.loads(raw)
82
+ except json.JSONDecodeError as exc:
83
+ raise GrixAuthError(f"invalid json response: {raw[:256]}", status=status) from exc
84
+
85
+ code = int(payload.get("code", -1))
86
+ msg = str(payload.get("msg", "")).strip() or "unknown error"
87
+ if status >= 400 or code != 0:
88
+ raise GrixAuthError(msg, status=status, code=code, payload=payload)
89
+
90
+ return {
91
+ "api_base_url": api_base_url,
92
+ "status": status,
93
+ "data": payload.get("data"),
94
+ "payload": payload,
95
+ }
96
+
97
+
98
+ def maybe_write_captcha_image(b64s: str):
99
+ text = (b64s or "").strip()
100
+ if not text.startswith("data:image/"):
101
+ return ""
102
+
103
+ marker = ";base64,"
104
+ idx = text.find(marker)
105
+ if idx < 0:
106
+ return ""
107
+
108
+ encoded = text[idx + len(marker) :]
109
+ try:
110
+ content = base64.b64decode(encoded)
111
+ except Exception:
112
+ return ""
113
+
114
+ fd, path = tempfile.mkstemp(prefix="grix-captcha-", suffix=".png")
115
+ try:
116
+ with os.fdopen(fd, "wb") as handle:
117
+ handle.write(content)
118
+ except Exception:
119
+ try:
120
+ os.unlink(path)
121
+ except OSError:
122
+ pass
123
+ return ""
124
+ return path
125
+
126
+
127
+ def print_json(payload):
128
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
129
+ sys.stdout.write("\n")
130
+
131
+
132
+ def build_auth_result(action: str, result: dict, base_url: str):
133
+ data = result.get("data") or {}
134
+ user = data.get("user") or {}
135
+ return {
136
+ "ok": True,
137
+ "action": action,
138
+ "api_base_url": result["api_base_url"],
139
+ "access_token": data.get("access_token", ""),
140
+ "refresh_token": data.get("refresh_token", ""),
141
+ "expires_in": data.get("expires_in", 0),
142
+ "user_id": user.get("id", ""),
143
+ "portal_url": derive_portal_url(base_url),
144
+ "data": data,
145
+ }
146
+
147
+
148
+ def build_agent_result(action: str, result: dict):
149
+ data = result.get("data") or {}
150
+ agent_id = str(data.get("id", "")).strip()
151
+ api_endpoint = str(data.get("api_endpoint", "")).strip()
152
+ api_key = str(data.get("api_key", "")).strip()
153
+ agent_name = str(data.get("agent_name", "")).strip()
154
+ bind_local_payload = {
155
+ "agent_name": agent_name,
156
+ "agent_id": agent_id,
157
+ "api_endpoint": api_endpoint,
158
+ "api_key": api_key,
159
+ }
160
+ handoff_task = "\n".join(
161
+ [
162
+ "bind-local",
163
+ f"agent_name={agent_name}",
164
+ f"agent_id={agent_id}",
165
+ f"api_endpoint={api_endpoint}",
166
+ f"api_key={api_key}",
167
+ "do_not_create_remote_agent=true",
168
+ ]
169
+ )
170
+
171
+ return {
172
+ "ok": True,
173
+ "action": action,
174
+ "api_base_url": result["api_base_url"],
175
+ "agent_id": agent_id,
176
+ "agent_name": agent_name,
177
+ "provider_type": data.get("provider_type", 0),
178
+ "api_endpoint": api_endpoint,
179
+ "api_key": api_key,
180
+ "api_key_hint": data.get("api_key_hint", ""),
181
+ "session_id": data.get("session_id", ""),
182
+ "handoff": {
183
+ "target_tool": "grix_admin",
184
+ "task": handoff_task,
185
+ "bind_local": bind_local_payload,
186
+ },
187
+ "data": data,
188
+ }
189
+
190
+
191
+ def login_with_credentials(base_url: str, account: str, password: str, device_id: str, platform: str):
192
+ result = request_json(
193
+ "POST",
194
+ "/auth/login",
195
+ base_url,
196
+ body={
197
+ "account": account,
198
+ "password": password,
199
+ "device_id": device_id,
200
+ "platform": platform,
201
+ },
202
+ )
203
+ return build_auth_result("login", result, base_url)
204
+
205
+
206
+ def create_api_agent(base_url: str, access_token: str, agent_name: str, avatar_url: str):
207
+ request_body = {
208
+ "agent_name": agent_name.strip(),
209
+ "provider_type": 3,
210
+ "is_main": True,
211
+ }
212
+ normalized_avatar_url = (avatar_url or "").strip()
213
+ if normalized_avatar_url:
214
+ request_body["avatar_url"] = normalized_avatar_url
215
+
216
+ result = request_json(
217
+ "POST",
218
+ "/agents/create",
219
+ base_url,
220
+ body=request_body,
221
+ headers={
222
+ "Authorization": f"Bearer {access_token.strip()}",
223
+ },
224
+ )
225
+ return build_agent_result("create-api-agent", result)
226
+
227
+
228
+ def list_agents(base_url: str, access_token: str):
229
+ result = request_json(
230
+ "GET",
231
+ "/agents/list",
232
+ base_url,
233
+ headers={
234
+ "Authorization": f"Bearer {access_token.strip()}",
235
+ },
236
+ )
237
+ data = result.get("data") or {}
238
+ items = data.get("list") or []
239
+ if not isinstance(items, list):
240
+ items = []
241
+ return items
242
+
243
+
244
+ def rotate_api_agent_key(base_url: str, access_token: str, agent_id: str):
245
+ result = request_json(
246
+ "POST",
247
+ f"/agents/{str(agent_id).strip()}/api/key/rotate",
248
+ base_url,
249
+ body={},
250
+ headers={
251
+ "Authorization": f"Bearer {access_token.strip()}",
252
+ },
253
+ )
254
+ return build_agent_result("rotate-api-agent-key", result)
255
+
256
+
257
+ def find_existing_api_agent(agents, agent_name: str):
258
+ normalized_name = (agent_name or "").strip()
259
+ if not normalized_name:
260
+ return None
261
+
262
+ for item in agents:
263
+ if not isinstance(item, dict):
264
+ continue
265
+ if str(item.get("agent_name", "")).strip() != normalized_name:
266
+ continue
267
+ if int(item.get("provider_type", 0) or 0) != 3:
268
+ continue
269
+ if int(item.get("status", 0) or 0) == 3:
270
+ continue
271
+ return item
272
+ return None
273
+
274
+
275
+ def create_or_reuse_api_agent(
276
+ base_url: str,
277
+ access_token: str,
278
+ agent_name: str,
279
+ avatar_url: str,
280
+ prefer_existing: bool,
281
+ rotate_on_reuse: bool,
282
+ ):
283
+ if prefer_existing:
284
+ agents = list_agents(base_url, access_token)
285
+ existing = find_existing_api_agent(agents, agent_name)
286
+ if existing is not None:
287
+ if not rotate_on_reuse:
288
+ raise GrixAuthError(
289
+ "existing provider_type=3 agent found but rotate-on-reuse is disabled; cannot obtain api_key safely",
290
+ payload={"existing_agent": existing},
291
+ )
292
+ rotated = rotate_api_agent_key(base_url, access_token, str(existing.get("id", "")).strip())
293
+ rotated["source"] = "reused_existing_agent_with_rotated_key"
294
+ rotated["existing_agent"] = existing
295
+ return rotated
296
+
297
+ created = create_api_agent(base_url, access_token, agent_name, avatar_url)
298
+ created["source"] = "created_new_agent"
299
+ return created
300
+
301
+
302
+ def default_device_id(platform: str) -> str:
303
+ normalized_platform = (platform or "").strip() or "web"
304
+ return f"{normalized_platform}_{uuid.uuid4()}"
305
+
306
+
307
+ def handle_fetch_captcha(args):
308
+ result = request_json("GET", "/auth/captcha", args.base_url)
309
+ data = result.get("data") or {}
310
+ image_path = maybe_write_captcha_image(str(data.get("b64s", "")))
311
+ payload = {
312
+ "ok": True,
313
+ "action": "fetch-captcha",
314
+ "api_base_url": result["api_base_url"],
315
+ "captcha_id": data.get("captcha_id", ""),
316
+ "b64s": data.get("b64s", ""),
317
+ }
318
+ if image_path:
319
+ payload["captcha_image_path"] = image_path
320
+ print_json(payload)
321
+
322
+
323
+ def handle_send_email_code(args):
324
+ scene = args.scene.strip()
325
+ payload = {
326
+ "email": args.email.strip(),
327
+ "scene": scene,
328
+ }
329
+
330
+ captcha_id = (args.captcha_id or "").strip()
331
+ captcha_value = (args.captcha_value or "").strip()
332
+ if scene in {"reset", "change_password"}:
333
+ if not captcha_id or not captcha_value:
334
+ raise GrixAuthError("captcha_id and captcha_value are required for reset/change_password")
335
+ if captcha_id:
336
+ payload["captcha_id"] = captcha_id
337
+ if captcha_value:
338
+ payload["captcha_value"] = captcha_value
339
+
340
+ result = request_json(
341
+ "POST",
342
+ "/auth/send-code",
343
+ args.base_url,
344
+ body=payload,
345
+ )
346
+ print_json(
347
+ {
348
+ "ok": True,
349
+ "action": "send-email-code",
350
+ "api_base_url": result["api_base_url"],
351
+ "data": result.get("data"),
352
+ }
353
+ )
354
+
355
+
356
+ def handle_register(args):
357
+ platform = (args.platform or "").strip() or "web"
358
+ device_id = (args.device_id or "").strip() or default_device_id(platform)
359
+ result = request_json(
360
+ "POST",
361
+ "/auth/register",
362
+ args.base_url,
363
+ body={
364
+ "email": args.email.strip(),
365
+ "password": args.password.strip(),
366
+ "email_code": args.email_code.strip(),
367
+ "device_id": device_id,
368
+ "platform": platform,
369
+ },
370
+ )
371
+ print_json(build_auth_result("register", result, args.base_url))
372
+
373
+
374
+ def handle_login(args):
375
+ account = (args.email or args.account or "").strip()
376
+ if not account:
377
+ raise GrixAuthError("either --email or --account is required")
378
+
379
+ platform = (args.platform or "").strip() or "web"
380
+ device_id = (args.device_id or "").strip() or default_device_id(platform)
381
+ print_json(
382
+ login_with_credentials(
383
+ args.base_url,
384
+ account,
385
+ args.password.strip(),
386
+ device_id,
387
+ platform,
388
+ )
389
+ )
390
+
391
+
392
+ def handle_create_api_agent(args):
393
+ print_json(
394
+ create_or_reuse_api_agent(
395
+ args.base_url,
396
+ args.access_token.strip(),
397
+ args.agent_name.strip(),
398
+ args.avatar_url,
399
+ not bool(args.no_reuse_existing_agent),
400
+ not bool(args.no_rotate_key_on_reuse),
401
+ )
402
+ )
403
+
404
+
405
+ def build_parser():
406
+ parser = argparse.ArgumentParser(description="Grix public auth API helper")
407
+ parser.add_argument(
408
+ "--base-url",
409
+ default=resolve_default_base_url(),
410
+ help="Grix web base URL (defaults to GRIX_WEB_BASE_URL or https://grix.dhf.pub)",
411
+ )
412
+
413
+ subparsers = parser.add_subparsers(dest="action", required=True)
414
+
415
+ fetch_captcha = subparsers.add_parser("fetch-captcha", help="Fetch captcha image")
416
+ fetch_captcha.set_defaults(handler=handle_fetch_captcha)
417
+
418
+ send_email_code = subparsers.add_parser("send-email-code", help="Send email verification code")
419
+ send_email_code.add_argument("--email", required=True)
420
+ send_email_code.add_argument("--scene", required=True, choices=["register", "reset", "change_password"])
421
+ send_email_code.add_argument("--captcha-id", default="")
422
+ send_email_code.add_argument("--captcha-value", default="")
423
+ send_email_code.set_defaults(handler=handle_send_email_code)
424
+
425
+ register = subparsers.add_parser("register", help="Register by email verification code")
426
+ register.add_argument("--email", required=True)
427
+ register.add_argument("--password", required=True)
428
+ register.add_argument("--email-code", required=True)
429
+ register.add_argument("--device-id", default="")
430
+ register.add_argument("--platform", default="web")
431
+ register.set_defaults(handler=handle_register)
432
+
433
+ login = subparsers.add_parser("login", help="Login and obtain tokens")
434
+ login_identity = login.add_mutually_exclusive_group(required=True)
435
+ login_identity.add_argument("--account")
436
+ login_identity.add_argument("--email")
437
+ login.add_argument("--password", required=True)
438
+ login.add_argument("--device-id", default="")
439
+ login.add_argument("--platform", default="web")
440
+ login.set_defaults(handler=handle_login)
441
+
442
+ create_api_agent_parser = subparsers.add_parser(
443
+ "create-api-agent",
444
+ help="Create a provider_type=3 API agent with a user access token",
445
+ )
446
+ create_api_agent_parser.add_argument("--access-token", required=True)
447
+ create_api_agent_parser.add_argument("--agent-name", required=True)
448
+ create_api_agent_parser.add_argument("--avatar-url", default="")
449
+ create_api_agent_parser.add_argument("--no-reuse-existing-agent", action="store_true")
450
+ create_api_agent_parser.add_argument("--no-rotate-key-on-reuse", action="store_true")
451
+ create_api_agent_parser.set_defaults(handler=handle_create_api_agent)
452
+
453
+ return parser
454
+
455
+
456
+ def main():
457
+ parser = build_parser()
458
+ args = parser.parse_args()
459
+ try:
460
+ args.handler(args)
461
+ except GrixAuthError as exc:
462
+ print_json(
463
+ {
464
+ "ok": False,
465
+ "action": args.action,
466
+ "status": exc.status,
467
+ "code": exc.code,
468
+ "error": str(exc),
469
+ "payload": exc.payload,
470
+ }
471
+ )
472
+ raise SystemExit(1)
473
+ except Exception as exc:
474
+ print_json(
475
+ {
476
+ "ok": False,
477
+ "action": args.action,
478
+ "status": 0,
479
+ "code": -1,
480
+ "error": str(exc),
481
+ }
482
+ )
483
+ raise SystemExit(1)
484
+
485
+
486
+ if __name__ == "__main__":
487
+ main()
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: grix-update
3
+ description: 需要检查或执行 OpenClaw 的 Grix 插件升级时使用。适用于手动维护或 cron 维护场景,只通过 `openclaw` 官方 CLI 完成检查、升级、校验、可选重启和健康检查。
4
+ ---
5
+
6
+ # Grix Update
7
+
8
+ 这个技能只做 OpenClaw 运维。
9
+
10
+ ## 输入
11
+
12
+ 建议把输入理解成:
13
+
14
+ - `mode`: `check-only` / `apply-update` / `check-and-apply`
15
+ - `plugin_id`: 默认 `grix`
16
+ - `allow_restart`: 默认 `true`
17
+
18
+ ## 执行顺序
19
+
20
+ 优先使用 helper:
21
+
22
+ ```bash
23
+ python3 scripts/grix_update.py --mode check-and-apply --plugin-id grix --allow-restart true --json
24
+ ```
25
+
26
+ 它内部会按当前 OpenClaw CLI 真实命令执行:
27
+
28
+ 1. `openclaw plugins inspect <plugin_id> --json`
29
+ 2. `openclaw plugins update <plugin_id> --dry-run`
30
+ 3. 按模式决定是否真正升级
31
+ 4. 升级后执行:
32
+ - `openclaw plugins doctor`
33
+ - `openclaw gateway restart`(仅在允许时)
34
+ - `openclaw health --json`
35
+
36
+ ## Guardrails
37
+
38
+ - 不要改用非官方脚本
39
+ - 如果 `allow_restart=false`,明确告诉上层运行态可能还是旧版本
40
+ - 如果没有明确通知目标,不要自行猜消息发送目标
41
+
42
+ ## 推荐 cron 接法
43
+
44
+ ```text
45
+ Use the grix-update skill with {"mode":"check-and-apply","plugin_id":"grix","notify_on":"never","allow_restart":true}
46
+ ```
47
+
48
+ ## 参考
49
+
50
+ - [Cron Setup](references/cron-setup.md)
@@ -0,0 +1,7 @@
1
+ interface:
2
+ display_name: "Grix Update"
3
+ short_description: "Check and apply OpenClaw Grix plugin updates."
4
+ default_prompt: "Use $grix-update to check and apply the latest Grix plugin update."
5
+
6
+ policy:
7
+ allow_implicit_invocation: true
@@ -0,0 +1,11 @@
1
+ # Cron Setup
2
+
3
+ 如果你要把 `grix-update` 接到定时任务,推荐让上层 cron 直接调用这个技能,而不是把升级逻辑散在多个地方。
4
+
5
+ 建议输入:
6
+
7
+ ```json
8
+ {"mode":"check-and-apply","plugin_id":"grix","allow_restart":true}
9
+ ```
10
+
11
+ 如果 cron 自己负责通知,技能侧不要再猜消息目标。
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python3
2
+ """Run OpenClaw Grix plugin update flow with a stable CLI surface."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import subprocess
9
+ import sys
10
+ from typing import Any
11
+
12
+
13
+ def run_command(cmd: list[str], *, check: bool = True) -> dict[str, Any]:
14
+ result = subprocess.run(cmd, text=True, capture_output=True)
15
+ payload = {
16
+ "cmd": cmd,
17
+ "code": result.returncode,
18
+ "stdout": (result.stdout or "").strip(),
19
+ "stderr": (result.stderr or "").strip(),
20
+ }
21
+ if check and result.returncode != 0:
22
+ raise RuntimeError(payload["stderr"] or payload["stdout"] or f"command failed: {' '.join(cmd)}")
23
+ return payload
24
+
25
+
26
+ def build_plan(args: argparse.Namespace) -> list[list[str]]:
27
+ openclaw_cmd = args.openclaw
28
+ plugin_id = args.plugin_id
29
+ inspect_cmd = [openclaw_cmd, "plugins", "inspect", plugin_id, "--json"]
30
+ update_probe_cmd = [openclaw_cmd, "plugins", "update", plugin_id, "--dry-run"]
31
+ apply_cmd = [openclaw_cmd, "plugins", "update", plugin_id]
32
+ doctor_cmd = [openclaw_cmd, "plugins", "doctor"]
33
+ restart_cmd = [openclaw_cmd, "gateway", "restart"]
34
+ health_cmd = [openclaw_cmd, "health", "--json"]
35
+
36
+ if args.mode == "check-only":
37
+ return [inspect_cmd, update_probe_cmd]
38
+
39
+ if args.mode == "apply-update":
40
+ commands = [inspect_cmd, apply_cmd, doctor_cmd]
41
+ if args.allow_restart:
42
+ commands.append(restart_cmd)
43
+ commands.append(health_cmd)
44
+ return commands
45
+
46
+ if args.mode == "check-and-apply":
47
+ commands = [inspect_cmd, update_probe_cmd, apply_cmd, doctor_cmd]
48
+ if args.allow_restart:
49
+ commands.append(restart_cmd)
50
+ commands.append(health_cmd)
51
+ return commands
52
+
53
+ raise RuntimeError(f"unsupported mode: {args.mode}")
54
+
55
+
56
+ def main() -> int:
57
+ parser = argparse.ArgumentParser(description="Run the Grix/OpenClaw plugin update workflow.")
58
+ parser.add_argument("--mode", choices=["check-only", "apply-update", "check-and-apply"], default="check-and-apply")
59
+ parser.add_argument("--plugin-id", default="grix")
60
+ parser.add_argument("--allow-restart", default="true", choices=["true", "false"])
61
+ parser.add_argument("--openclaw", default="openclaw")
62
+ parser.add_argument("--dry-run", action="store_true")
63
+ parser.add_argument("--json", action="store_true")
64
+ args = parser.parse_args()
65
+ args.allow_restart = args.allow_restart == "true"
66
+
67
+ try:
68
+ commands = build_plan(args)
69
+ results: list[dict[str, Any]] = []
70
+ if not args.dry_run:
71
+ for cmd in commands:
72
+ results.append(run_command(cmd))
73
+ payload = {
74
+ "ok": True,
75
+ "mode": args.mode,
76
+ "plugin_id": args.plugin_id,
77
+ "allow_restart": args.allow_restart,
78
+ "dry_run": bool(args.dry_run),
79
+ "commands": commands,
80
+ "results": results,
81
+ }
82
+ if args.json:
83
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
84
+ else:
85
+ print(f"mode={args.mode} plugin_id={args.plugin_id} dry_run={args.dry_run}")
86
+ for cmd in commands:
87
+ print("$ " + " ".join(cmd))
88
+ return 0
89
+ except Exception as exc: # noqa: BLE001
90
+ payload = {"ok": False, "error": str(exc)}
91
+ if args.json:
92
+ print(json.dumps(payload, ensure_ascii=False, indent=2), file=sys.stderr)
93
+ else:
94
+ print(str(exc), file=sys.stderr)
95
+ return 1
96
+
97
+
98
+ if __name__ == "__main__":
99
+ raise SystemExit(main())