@dhf-openclaw/grix 0.4.9 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,587 @@
1
+ #!/usr/bin/env python3
2
+ import argparse
3
+ import json
4
+ import os
5
+ import shlex
6
+ import subprocess
7
+ import sys
8
+ import uuid
9
+
10
+
11
+ DEFAULT_OPENCLAW_CONFIG_PATH = "~/.openclaw/openclaw.json"
12
+ DEFAULT_OPENCLAW_TOOLS_PROFILE = "coding"
13
+ DEFAULT_OPENCLAW_TOOLS_VISIBILITY = "agent"
14
+ REQUIRED_OPENCLAW_TOOLS = [
15
+ "message",
16
+ "grix_group",
17
+ "grix_agent_admin",
18
+ ]
19
+
20
+
21
+ class BindError(RuntimeError):
22
+ def __init__(self, message, payload=None):
23
+ super().__init__(message)
24
+ self.payload = payload
25
+
26
+
27
+ def print_json(payload):
28
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
29
+ sys.stdout.write("\n")
30
+
31
+
32
+ def expand_path(path: str) -> str:
33
+ return os.path.abspath(os.path.expanduser((path or "").strip() or DEFAULT_OPENCLAW_CONFIG_PATH))
34
+
35
+
36
+ def resolve_config_path(args) -> str:
37
+ raw_path = str(getattr(args, "config_path", "") or "").strip()
38
+ if raw_path and raw_path != DEFAULT_OPENCLAW_CONFIG_PATH:
39
+ return expand_path(raw_path)
40
+
41
+ profile = str(getattr(args, "openclaw_profile", "") or "").strip()
42
+ if profile:
43
+ return expand_path(f"~/.openclaw-{profile}/openclaw.json")
44
+
45
+ return expand_path(DEFAULT_OPENCLAW_CONFIG_PATH)
46
+
47
+
48
+ def load_json_file(path: str):
49
+ if not os.path.exists(path):
50
+ return {}
51
+ with open(path, "r", encoding="utf-8") as handle:
52
+ raw = handle.read().strip()
53
+ if not raw:
54
+ return {}
55
+ return json.loads(raw)
56
+
57
+
58
+ def write_json_file_with_backup(path: str, payload):
59
+ os.makedirs(os.path.dirname(path), exist_ok=True)
60
+ backup_path = ""
61
+ if os.path.exists(path):
62
+ backup_path = f"{path}.bak.{uuid.uuid4().hex[:8]}"
63
+ with open(path, "rb") as src, open(backup_path, "wb") as dst:
64
+ dst.write(src.read())
65
+ with open(path, "w", encoding="utf-8") as handle:
66
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
67
+ handle.write("\n")
68
+ return backup_path
69
+
70
+
71
+ def normalize_string_list(values):
72
+ if not isinstance(values, list):
73
+ return []
74
+ normalized = []
75
+ seen = set()
76
+ for item in values:
77
+ text = str(item or "").strip()
78
+ if not text or text in seen:
79
+ continue
80
+ seen.add(text)
81
+ normalized.append(text)
82
+ return normalized
83
+
84
+
85
+ def mask_secret(value: str):
86
+ text = str(value or "").strip()
87
+ if not text:
88
+ return ""
89
+ if len(text) <= 8:
90
+ return "*" * len(text)
91
+ return f"{text[:4]}...{text[-4:]}"
92
+
93
+
94
+ def redact_channel_account(account):
95
+ payload = dict(account or {})
96
+ api_key = str(payload.get("apiKey", "")).strip()
97
+ if api_key:
98
+ payload["apiKey"] = "<redacted>"
99
+ payload["apiKeyMasked"] = mask_secret(api_key)
100
+ return payload
101
+
102
+
103
+ def shell_command(cmd):
104
+ return " ".join(shlex.quote(part) for part in cmd)
105
+
106
+
107
+ def run_command_capture(cmd):
108
+ proc = subprocess.run(cmd, capture_output=True, text=True)
109
+ return {
110
+ "command": shell_command(cmd),
111
+ "returncode": proc.returncode,
112
+ "stdout": proc.stdout.strip(),
113
+ "stderr": proc.stderr.strip(),
114
+ }
115
+
116
+
117
+ def build_openclaw_base_cmd(args):
118
+ base_cmd = [(args.openclaw_bin or "").strip() or "openclaw"]
119
+ profile = str(getattr(args, "openclaw_profile", "") or "").strip()
120
+ if profile:
121
+ base_cmd.extend(["--profile", profile])
122
+ return base_cmd
123
+
124
+
125
+ def build_gateway_restart_command(args):
126
+ return build_openclaw_base_cmd(args) + ["gateway", "restart"]
127
+
128
+
129
+ def ensure_agent_entry(cfg, target_agent):
130
+ next_cfg = dict(cfg or {})
131
+ agents = dict(next_cfg.get("agents") or {})
132
+ current_list = agents.get("list")
133
+ if not isinstance(current_list, list):
134
+ current_list = []
135
+ next_list = list(current_list)
136
+
137
+ changed = False
138
+ found_index = None
139
+ for idx, item in enumerate(next_list):
140
+ if not isinstance(item, dict):
141
+ continue
142
+ if str(item.get("id", "")).strip() == target_agent["id"]:
143
+ found_index = idx
144
+ break
145
+
146
+ if found_index is None:
147
+ next_list.append(dict(target_agent))
148
+ changed = True
149
+ else:
150
+ existing = dict(next_list[found_index] or {})
151
+ merged = dict(existing)
152
+ merged.update(target_agent)
153
+ if merged != existing:
154
+ next_list[found_index] = merged
155
+ changed = True
156
+
157
+ agents["list"] = next_list
158
+ next_cfg["agents"] = agents
159
+ return next_cfg, changed
160
+
161
+
162
+ def ensure_channel_account(cfg, agent_name: str, target_account):
163
+ next_cfg = dict(cfg or {})
164
+ channels = dict(next_cfg.get("channels") or {})
165
+ grix = dict(channels.get("grix") or {})
166
+ accounts = dict(grix.get("accounts") or {})
167
+
168
+ changed = False
169
+ if not bool(grix.get("enabled", False)):
170
+ grix["enabled"] = True
171
+ changed = True
172
+
173
+ existing = dict(accounts.get(agent_name) or {})
174
+ merged = dict(existing)
175
+ merged.update(target_account)
176
+ if merged != existing:
177
+ accounts[agent_name] = merged
178
+ changed = True
179
+
180
+ grix["accounts"] = accounts
181
+ channels["grix"] = grix
182
+ next_cfg["channels"] = channels
183
+ return next_cfg, changed
184
+
185
+
186
+ def ensure_route_binding(cfg, agent_name: str):
187
+ next_cfg = dict(cfg or {})
188
+ current_bindings = next_cfg.get("bindings")
189
+ if not isinstance(current_bindings, list):
190
+ current_bindings = []
191
+ bindings = list(current_bindings)
192
+
193
+ changed = False
194
+ best_index = None
195
+ for idx, item in enumerate(bindings):
196
+ if not isinstance(item, dict):
197
+ continue
198
+ if str(item.get("type", "")).strip() != "route":
199
+ continue
200
+ if str(item.get("agentId", "")).strip() != agent_name:
201
+ continue
202
+ match = item.get("match") if isinstance(item.get("match"), dict) else {}
203
+ if str(match.get("channel", "")).strip() != "grix":
204
+ continue
205
+ best_index = idx
206
+ break
207
+
208
+ if best_index is None:
209
+ bindings.append(
210
+ {
211
+ "type": "route",
212
+ "agentId": agent_name,
213
+ "match": {
214
+ "channel": "grix",
215
+ "accountId": agent_name,
216
+ },
217
+ }
218
+ )
219
+ changed = True
220
+ else:
221
+ existing = dict(bindings[best_index] or {})
222
+ match = dict(existing.get("match") or {})
223
+ if str(match.get("accountId", "")).strip() != agent_name:
224
+ match["accountId"] = agent_name
225
+ existing["match"] = match
226
+ bindings[best_index] = existing
227
+ changed = True
228
+
229
+ next_cfg["bindings"] = bindings
230
+ return next_cfg, changed
231
+
232
+
233
+ def ensure_required_tools(cfg):
234
+ next_cfg = dict(cfg or {})
235
+ tools = dict(next_cfg.get("tools") or {})
236
+ sessions = dict(tools.get("sessions") or {})
237
+ changed = False
238
+
239
+ if str(tools.get("profile", "")).strip() != DEFAULT_OPENCLAW_TOOLS_PROFILE:
240
+ tools["profile"] = DEFAULT_OPENCLAW_TOOLS_PROFILE
241
+ changed = True
242
+
243
+ also_allow = normalize_string_list(tools.get("alsoAllow"))
244
+ next_also_allow = list(also_allow)
245
+ for tool_id in REQUIRED_OPENCLAW_TOOLS:
246
+ if tool_id not in next_also_allow:
247
+ next_also_allow.append(tool_id)
248
+ changed = True
249
+ tools["alsoAllow"] = next_also_allow
250
+
251
+ if str(sessions.get("visibility", "")).strip() != DEFAULT_OPENCLAW_TOOLS_VISIBILITY:
252
+ sessions["visibility"] = DEFAULT_OPENCLAW_TOOLS_VISIBILITY
253
+ changed = True
254
+ tools["sessions"] = sessions
255
+
256
+ next_cfg["tools"] = tools
257
+ return next_cfg, changed
258
+
259
+
260
+ def resolve_default_model(cfg, current_agent):
261
+ if isinstance(current_agent, dict):
262
+ model = str(current_agent.get("model", "")).strip()
263
+ if model:
264
+ return model
265
+ agents = cfg.get("agents") if isinstance(cfg, dict) else {}
266
+ defaults = agents.get("defaults") if isinstance(agents, dict) else {}
267
+ model_cfg = defaults.get("model") if isinstance(defaults, dict) else {}
268
+ model = str(model_cfg.get("primary", "")).strip() if isinstance(model_cfg, dict) else ""
269
+ return model
270
+
271
+
272
+ def extract_current_state(cfg, agent_name: str):
273
+ agents = cfg.get("agents") if isinstance(cfg, dict) else {}
274
+ agent_list = agents.get("list") if isinstance(agents, dict) else []
275
+ if not isinstance(agent_list, list):
276
+ agent_list = []
277
+
278
+ current_agent = None
279
+ for item in agent_list:
280
+ if not isinstance(item, dict):
281
+ continue
282
+ if str(item.get("id", "")).strip() == agent_name:
283
+ current_agent = item
284
+ break
285
+
286
+ channels = cfg.get("channels") if isinstance(cfg, dict) else {}
287
+ grix = channels.get("grix") if isinstance(channels, dict) else {}
288
+ accounts = grix.get("accounts") if isinstance(grix, dict) else {}
289
+ current_account = accounts.get(agent_name) if isinstance(accounts, dict) else None
290
+
291
+ current_binding = None
292
+ bindings = cfg.get("bindings") if isinstance(cfg, dict) else []
293
+ if not isinstance(bindings, list):
294
+ bindings = []
295
+ for item in bindings:
296
+ if not isinstance(item, dict):
297
+ continue
298
+ if str(item.get("type", "")).strip() != "route":
299
+ continue
300
+ if str(item.get("agentId", "")).strip() != agent_name:
301
+ continue
302
+ match = item.get("match") if isinstance(item.get("match"), dict) else {}
303
+ if str(match.get("channel", "")).strip() != "grix":
304
+ continue
305
+ current_binding = item
306
+ break
307
+
308
+ tools = cfg.get("tools") if isinstance(cfg, dict) else {}
309
+ if not isinstance(tools, dict):
310
+ tools = {}
311
+ sessions = tools.get("sessions") if isinstance(tools.get("sessions"), dict) else {}
312
+ return {
313
+ "agent_entry": current_agent,
314
+ "channel_account": current_account,
315
+ "route_binding": current_binding,
316
+ "tools_config": {
317
+ "profile": str(tools.get("profile", "")).strip(),
318
+ "alsoAllow": normalize_string_list(tools.get("alsoAllow")),
319
+ "sessions": {
320
+ "visibility": str(sessions.get("visibility", "")).strip(),
321
+ },
322
+ },
323
+ }
324
+
325
+
326
+ def build_workspace_files(workspace_dir: str, agent_name: str):
327
+ files = {
328
+ "AGENTS.md": f"# {agent_name}\n\nGrix bound agent profile for `{agent_name}`.\n",
329
+ "MEMORY.md": f"# Memory\n\n- owner: {agent_name}\n",
330
+ "USER.md": f"# User\n\nCurrent active account: `{agent_name}`.\n",
331
+ }
332
+ created = []
333
+ os.makedirs(workspace_dir, exist_ok=True)
334
+ for filename, content in files.items():
335
+ path = os.path.join(workspace_dir, filename)
336
+ if os.path.exists(path):
337
+ continue
338
+ with open(path, "w", encoding="utf-8") as handle:
339
+ handle.write(content)
340
+ created.append(path)
341
+ return created
342
+
343
+
344
+ def handle_inspect_local(args):
345
+ agent_name = str(args.agent_name or "").strip()
346
+ if not agent_name:
347
+ raise BindError("--agent-name is required")
348
+
349
+ config_path = resolve_config_path(args)
350
+ cfg = load_json_file(config_path)
351
+ current = extract_current_state(cfg, agent_name)
352
+
353
+ agent_entry = current.get("agent_entry") if isinstance(current, dict) else None
354
+ channel_account = current.get("channel_account") if isinstance(current, dict) else None
355
+ route_binding = current.get("route_binding") if isinstance(current, dict) else None
356
+ tools = current.get("tools_config") if isinstance(current, dict) else {}
357
+ also_allow = normalize_string_list((tools or {}).get("alsoAllow"))
358
+ visibility = str(((tools or {}).get("sessions") or {}).get("visibility", "")).strip()
359
+ profile = str((tools or {}).get("profile", "")).strip()
360
+
361
+ has_required_tools = all(item in also_allow for item in REQUIRED_OPENCLAW_TOOLS)
362
+ tools_ready = profile == DEFAULT_OPENCLAW_TOOLS_PROFILE and has_required_tools and visibility == DEFAULT_OPENCLAW_TOOLS_VISIBILITY
363
+ account_ready = isinstance(channel_account, dict) and bool(str(channel_account.get("apiKey", "")).strip()) and bool(str(channel_account.get("wsUrl", "")).strip()) and bool(str(channel_account.get("agentId", "")).strip())
364
+ binding_ready = isinstance(route_binding, dict)
365
+
366
+ print_json(
367
+ {
368
+ "ok": True,
369
+ "action": "inspect-local-openclaw",
370
+ "config_path": config_path,
371
+ "agent_name": agent_name,
372
+ "ready": bool(agent_entry) and account_ready and binding_ready and tools_ready,
373
+ "checks": {
374
+ "agent_entry_exists": bool(agent_entry),
375
+ "channel_account_ready": account_ready,
376
+ "route_binding_exists": binding_ready,
377
+ "tools_ready": tools_ready,
378
+ },
379
+ "current_state": {
380
+ "agent_entry": agent_entry,
381
+ "channel_account": redact_channel_account(channel_account or {}),
382
+ "route_binding": route_binding,
383
+ "tools_config": tools,
384
+ },
385
+ }
386
+ )
387
+
388
+
389
+ def handle_configure_local(args):
390
+ agent_name = str(args.agent_name or "").strip()
391
+ agent_id = str(args.agent_id or "").strip()
392
+ api_endpoint = str(args.api_endpoint or "").strip()
393
+ api_key = str(args.api_key or "").strip()
394
+ if not agent_name:
395
+ raise BindError("--agent-name is required")
396
+ if not agent_id:
397
+ raise BindError("--agent-id is required")
398
+ if not api_endpoint:
399
+ raise BindError("--api-endpoint is required")
400
+ if not api_key:
401
+ raise BindError("--api-key is required")
402
+
403
+ config_path = resolve_config_path(args)
404
+ cfg = load_json_file(config_path)
405
+ current = extract_current_state(cfg, agent_name)
406
+ current_agent = current.get("agent_entry") if isinstance(current, dict) else None
407
+
408
+ model = str(args.model or "").strip() or resolve_default_model(cfg, current_agent)
409
+ if not model:
410
+ raise BindError(
411
+ "unable to resolve agent model from args or openclaw config; pass --model explicitly"
412
+ )
413
+
414
+ workspace = expand_path(str(args.workspace or "").strip() or f"~/.openclaw/workspace-{agent_name}")
415
+ agent_dir = expand_path(str(args.agent_dir or "").strip() or f"~/.openclaw/agents/{agent_name}/agent")
416
+
417
+ target_agent = {
418
+ "id": agent_name,
419
+ "name": agent_name,
420
+ "workspace": workspace,
421
+ "agentDir": agent_dir,
422
+ "model": model,
423
+ }
424
+ target_account = {
425
+ "name": agent_name,
426
+ "enabled": True,
427
+ "apiKey": api_key,
428
+ "wsUrl": api_endpoint,
429
+ "agentId": agent_id,
430
+ }
431
+
432
+ next_cfg = dict(cfg or {})
433
+ change_flags = {
434
+ "agent_entry_updated": False,
435
+ "channel_account_updated": False,
436
+ "route_binding_updated": False,
437
+ "tools_updated": False,
438
+ }
439
+
440
+ next_cfg, changed = ensure_agent_entry(next_cfg, target_agent)
441
+ change_flags["agent_entry_updated"] = changed
442
+ next_cfg, changed = ensure_channel_account(next_cfg, agent_name, target_account)
443
+ change_flags["channel_account_updated"] = changed
444
+ next_cfg, changed = ensure_route_binding(next_cfg, agent_name)
445
+ change_flags["route_binding_updated"] = changed
446
+
447
+ if not bool(args.skip_tools_update):
448
+ next_cfg, changed = ensure_required_tools(next_cfg)
449
+ change_flags["tools_updated"] = changed
450
+
451
+ needs_update = any(bool(value) for value in change_flags.values())
452
+
453
+ payload = {
454
+ "ok": True,
455
+ "action": "configure-local-openclaw",
456
+ "apply": bool(args.apply),
457
+ "config_path": config_path,
458
+ "agent_name": agent_name,
459
+ "changes": change_flags,
460
+ "needs_update": needs_update,
461
+ "current_state": {
462
+ "agent_entry": (current or {}).get("agent_entry"),
463
+ "channel_account": redact_channel_account((current or {}).get("channel_account") or {}),
464
+ "route_binding": (current or {}).get("route_binding"),
465
+ "tools_config": (current or {}).get("tools_config"),
466
+ },
467
+ "next_state": {
468
+ "agent_entry": target_agent,
469
+ "channel_account": redact_channel_account(target_account),
470
+ "route_binding": {
471
+ "type": "route",
472
+ "agentId": agent_name,
473
+ "match": {
474
+ "channel": "grix",
475
+ "accountId": agent_name,
476
+ },
477
+ },
478
+ "tools_requirements": {
479
+ "profile": DEFAULT_OPENCLAW_TOOLS_PROFILE,
480
+ "alsoAllow": list(REQUIRED_OPENCLAW_TOOLS),
481
+ "sessions": {
482
+ "visibility": DEFAULT_OPENCLAW_TOOLS_VISIBILITY,
483
+ },
484
+ },
485
+ },
486
+ "planned_apply_commands": [] if bool(args.skip_gateway_restart) else [shell_command(build_gateway_restart_command(args))],
487
+ }
488
+
489
+ if args.apply:
490
+ backup_path = ""
491
+ if needs_update:
492
+ backup_path = write_json_file_with_backup(config_path, next_cfg)
493
+ created_paths = []
494
+ created_paths.extend(build_workspace_files(workspace, agent_name))
495
+ os.makedirs(agent_dir, exist_ok=True)
496
+
497
+ command_results = []
498
+ if not bool(args.skip_gateway_restart):
499
+ command_results.append(run_command_capture(build_gateway_restart_command(args)))
500
+ if command_results[-1]["returncode"] != 0:
501
+ raise BindError(
502
+ "openclaw gateway restart failed",
503
+ payload={"command_results": command_results},
504
+ )
505
+
506
+ applied_cfg = load_json_file(config_path)
507
+ applied_state = extract_current_state(applied_cfg, agent_name)
508
+ payload["config_write"] = {
509
+ "changed": needs_update,
510
+ "backup_path": backup_path,
511
+ }
512
+ payload["created_workspace_files"] = created_paths
513
+ payload["command_results"] = command_results
514
+ payload["applied_state"] = {
515
+ "agent_entry": (applied_state or {}).get("agent_entry"),
516
+ "channel_account": redact_channel_account((applied_state or {}).get("channel_account") or {}),
517
+ "route_binding": (applied_state or {}).get("route_binding"),
518
+ "tools_config": (applied_state or {}).get("tools_config"),
519
+ }
520
+ print_json(payload)
521
+
522
+
523
+ def build_parser():
524
+ parser = argparse.ArgumentParser(description="Configure local OpenClaw agent + grix channel binding")
525
+ subparsers = parser.add_subparsers(dest="action", required=True)
526
+
527
+ def add_common_local_args(target_parser):
528
+ target_parser.add_argument("--openclaw-bin", dest="openclaw_bin", default="openclaw")
529
+ target_parser.add_argument("--openclaw-profile", dest="openclaw_profile", default="")
530
+ target_parser.add_argument("--config-path", default=DEFAULT_OPENCLAW_CONFIG_PATH)
531
+
532
+ inspect_local = subparsers.add_parser(
533
+ "inspect-local-openclaw",
534
+ help="Inspect local OpenClaw agent + grix account binding state",
535
+ )
536
+ add_common_local_args(inspect_local)
537
+ inspect_local.add_argument("--agent-name", required=True)
538
+ inspect_local.set_defaults(handler=handle_inspect_local)
539
+
540
+ configure_local = subparsers.add_parser(
541
+ "configure-local-openclaw",
542
+ help="Preview or apply local OpenClaw agent + grix account binding",
543
+ )
544
+ add_common_local_args(configure_local)
545
+ configure_local.add_argument("--agent-name", required=True)
546
+ configure_local.add_argument("--agent-id", required=True)
547
+ configure_local.add_argument("--api-endpoint", required=True)
548
+ configure_local.add_argument("--api-key", required=True)
549
+ configure_local.add_argument("--model", default="")
550
+ configure_local.add_argument("--workspace", default="")
551
+ configure_local.add_argument("--agent-dir", default="")
552
+ configure_local.add_argument("--skip-tools-update", action="store_true")
553
+ configure_local.add_argument("--skip-gateway-restart", action="store_true")
554
+ configure_local.add_argument("--apply", action="store_true")
555
+ configure_local.set_defaults(handler=handle_configure_local)
556
+
557
+ return parser
558
+
559
+
560
+ def main():
561
+ parser = build_parser()
562
+ args = parser.parse_args()
563
+ try:
564
+ args.handler(args)
565
+ except BindError as exc:
566
+ print_json(
567
+ {
568
+ "ok": False,
569
+ "action": args.action,
570
+ "error": str(exc),
571
+ "payload": exc.payload,
572
+ }
573
+ )
574
+ raise SystemExit(1)
575
+ except Exception as exc:
576
+ print_json(
577
+ {
578
+ "ok": False,
579
+ "action": args.action,
580
+ "error": str(exc),
581
+ }
582
+ )
583
+ raise SystemExit(1)
584
+
585
+
586
+ if __name__ == "__main__":
587
+ main()