@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
package/.gitignore ADDED
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ .DS_Store
3
+ *.log
4
+ dist/
5
+ tmp/
6
+ coverage/
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
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.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # grix-hermes
2
+
3
+ `grix-hermes` 是一个独立发布的 Hermes 技能包项目。
4
+ 它对外发布到 npm 的包名是 `@dhf-hermes/grix`,安装后的命令仍然是 `grix-hermes`。
5
+
6
+ 它的目标很单一:
7
+
8
+ - 不修改 Hermes 内核
9
+ - 复用 Hermes 已有的 `terminal` 和 `send_message`
10
+ - 把 Grix / OpenClaw 相关技能做成可单独安装、可 npm 发布、可 GitHub 发布的技能包
11
+
12
+ ## 包含内容
13
+
14
+ - 9 个迁移后的技能
15
+ - 一套共享 Grix WS CLI,给 `grix-query`、`grix-group`、`grix-admin`、`message-unsend` 使用
16
+ - 独立 HTTP 组件,给 `grix-register` 使用
17
+ - OpenClaw 本地运维脚本,给 `openclaw-memory-setup` 使用
18
+
19
+ ## 安装
20
+
21
+ ### 方式 1:npm
22
+
23
+ ```bash
24
+ npm install -g @dhf-hermes/grix
25
+ grix-hermes install
26
+ ```
27
+
28
+ 默认会安装到:
29
+
30
+ ```text
31
+ ~/.hermes/skills/grix-hermes
32
+ ```
33
+
34
+ 如果你用了自定义 `HERMES_HOME`,安装器会跟随它。
35
+
36
+ ### 方式 2:GitHub / 本地目录
37
+
38
+ ```bash
39
+ git clone <repo> /path/to/grix-hermes
40
+ ```
41
+
42
+ 然后可以二选一:
43
+
44
+ 1. 直接把仓库根目录作为 `skills.external_dirs`
45
+ 2. 执行 `node ./bin/grix-hermes.mjs install --dest <目标目录>`
46
+
47
+ ## 命令
48
+
49
+ ```bash
50
+ grix-hermes list
51
+ grix-hermes manifest
52
+ grix-hermes install
53
+ grix-hermes install --dest ~/.hermes/skills/grix-hermes --force
54
+ ```
55
+
56
+ ## GitHub / npm 发布
57
+
58
+ 仓库已经带了两条工作流:
59
+
60
+ - `.github/workflows/ci.yml`
61
+ - `.github/workflows/publish.yml`
62
+
63
+ 发布时只需要准备:
64
+
65
+ 1. GitHub 仓库
66
+ 2. npm 包名:`@dhf-hermes/grix`
67
+ 3. GitHub Secret: `NPM_TOKEN`
68
+
69
+ 仓库里也带了本地发布脚本:
70
+
71
+ - `./publish.sh`
72
+ - `./scripts/publish_npm.sh`
73
+
74
+ 常用方式:
75
+
76
+ ```bash
77
+ bash ./publish.sh
78
+ bash ./publish.sh --publish
79
+ ```
80
+
81
+ ## 设计边界
82
+
83
+ - Grix 查询、群管理、远端 Agent 管理、消息撤回:走 Grix WS 协议
84
+ - 注册、发验证码、创建首个 API agent:走独立 HTTP 组件
85
+ - 发消息、发卡片:优先使用 Hermes 自带 `send_message`
86
+ - 本项目不依赖修改 Hermes 内核,也不要求给 Hermes 增加新 tool
87
+
88
+ ## 技能清单
89
+
90
+ - `grix-admin`
91
+ - `grix-egg`
92
+ - `grix-group`
93
+ - `grix-query`
94
+ - `grix-register`
95
+ - `grix-update`
96
+ - `message-send`
97
+ - `message-unsend`
98
+ - `openclaw-memory-setup`
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { defaultInstallDir, installEntries, manifestData, projectRoot, SKILLS } from "../lib/manifest.mjs";
6
+
7
+ function printHelp() {
8
+ console.log(`grix-hermes
9
+
10
+ Usage:
11
+ grix-hermes list
12
+ grix-hermes manifest
13
+ grix-hermes install [--dest <dir>] [--force]
14
+ `);
15
+ }
16
+
17
+ function parseArgs(argv) {
18
+ const positional = [];
19
+ const flags = {};
20
+ for (let i = 0; i < argv.length; i += 1) {
21
+ const token = argv[i];
22
+ if (!token.startsWith("--")) {
23
+ positional.push(token);
24
+ continue;
25
+ }
26
+ const key = token.slice(2);
27
+ const next = argv[i + 1];
28
+ if (!next || next.startsWith("--")) {
29
+ flags[key] = true;
30
+ continue;
31
+ }
32
+ flags[key] = next;
33
+ i += 1;
34
+ }
35
+ return { positional, flags };
36
+ }
37
+
38
+ function copyRecursive(src, dest, force) {
39
+ if (fs.existsSync(dest)) {
40
+ if (!force) {
41
+ throw new Error(`Destination already exists: ${dest}. Use --force to overwrite.`);
42
+ }
43
+ fs.rmSync(dest, { recursive: true, force: true });
44
+ }
45
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
46
+ fs.cpSync(src, dest, { recursive: true });
47
+ }
48
+
49
+ function runInstall(flags) {
50
+ const root = projectRoot();
51
+ const destRoot = path.resolve(String(flags.dest || defaultInstallDir()));
52
+ fs.mkdirSync(destRoot, { recursive: true });
53
+ for (const entry of installEntries()) {
54
+ copyRecursive(path.join(root, entry), path.join(destRoot, entry), Boolean(flags.force));
55
+ }
56
+ console.log(destRoot);
57
+ }
58
+
59
+ function runList() {
60
+ for (const skill of SKILLS) {
61
+ console.log(`${skill.name}\t${skill.description}`);
62
+ }
63
+ }
64
+
65
+ function runManifest() {
66
+ console.log(JSON.stringify(manifestData(), null, 2));
67
+ }
68
+
69
+ const { positional, flags } = parseArgs(process.argv.slice(2));
70
+ const command = positional[0] || "help";
71
+
72
+ if (command === "help" || command === "--help" || command === "-h") {
73
+ printHelp();
74
+ process.exit(0);
75
+ }
76
+
77
+ if (command === "list") {
78
+ runList();
79
+ process.exit(0);
80
+ }
81
+
82
+ if (command === "manifest") {
83
+ runManifest();
84
+ process.exit(0);
85
+ }
86
+
87
+ if (command === "install") {
88
+ runInstall(flags);
89
+ process.exit(0);
90
+ }
91
+
92
+ printHelp();
93
+ process.exit(1);
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: grix-admin
3
+ description: 需要创建远端 Grix API agent、管理分类、并把结果落地到本地 OpenClaw 配置时使用。适用于首个 agent 绑定、后续创建并绑定 agent、分类管理。远端步骤通过 `../shared/cli/grix-hermes.mjs admin`,本地步骤通过 `openclaw` 官方 CLI。
4
+ ---
5
+
6
+ # Grix Admin
7
+
8
+ 这个技能负责两件事:
9
+
10
+ 1. 远端 Grix agent / 分类管理
11
+ 2. 本地 OpenClaw 绑定和校验
12
+
13
+ 不要手工改 `openclaw.json`。
14
+
15
+ ## Mode A: bind-local
16
+
17
+ 当上下文已经给出:
18
+
19
+ - `agent_name`
20
+ - `agent_id`
21
+ - `api_endpoint`
22
+ - `api_key`
23
+
24
+ 就直接做本地绑定,不做远端创建。
25
+
26
+ ### 本地绑定主线
27
+
28
+ 1. 先用 `openclaw config get --json` 读取:
29
+ - `channels.grix.accounts`
30
+ - `agents.list`
31
+ - `tools.profile`
32
+ - `tools.alsoAllow`
33
+ - `tools.sessions.visibility`
34
+ 2. 准备目标值:
35
+ - `channels.grix.accounts.<agent_name>`
36
+ - `agents.list` 里目标 agent
37
+ - `tools.profile="coding"`
38
+ - `tools.alsoAllow` 至少包含 `message`、`grix_register`
39
+ 3. 用官方命令写入:
40
+ - `openclaw config set ... --strict-json`
41
+ - `openclaw agents bind --agent <agent_name> --bind grix:<agent_name>`
42
+ 4. 校验:
43
+ - `openclaw config validate`
44
+ - `openclaw config get --json channels.grix.accounts.<agent_name>`
45
+ - `openclaw agents bindings --agent <agent_name> --json`
46
+
47
+ ### model 规则
48
+
49
+ - 先复用该 agent 现有 `model`
50
+ - 没有则看 `agents.defaults.model.primary`
51
+ - 还没有就停止,不要猜
52
+
53
+ ## Mode B: create-and-bind
54
+
55
+ 如果还没有远端 agent,就先创建。
56
+
57
+ ### 远端创建
58
+
59
+ 通过 `terminal` 执行:
60
+
61
+ ```bash
62
+ node scripts/admin.mjs --action create_agent --agent-name <NAME> [--introduction ...] [--is-main true|false]
63
+ ```
64
+
65
+ 如果需要分类:
66
+
67
+ ```bash
68
+ node scripts/admin.mjs --action create_agent --agent-name <NAME> --category-id <ID>
69
+ node scripts/admin.mjs --action create_agent --agent-name <NAME> --category-name <NAME> --parent-category-id 0
70
+ ```
71
+
72
+ 创建成功后,拿返回里的 `id` / `agent_name` / `api_endpoint` / `api_key`,继续走本地绑定 helper:
73
+
74
+ ```bash
75
+ python3 scripts/bind_local.py \
76
+ --agent-name <AGENT_NAME> \
77
+ --agent-id <AGENT_ID> \
78
+ --api-endpoint <WS_URL> \
79
+ --api-key <API_KEY> \
80
+ --model <MODEL>
81
+ ```
82
+
83
+ 如果你已经拿到一份远端创建结果 JSON,可以直接交给:
84
+
85
+ ```bash
86
+ python3 scripts/bind_from_json.py --model <MODEL> --dry-run --json < result.json
87
+ ```
88
+
89
+ ## Mode C: category-manage
90
+
91
+ 分类相关动作统一走:
92
+
93
+ ```bash
94
+ node scripts/admin.mjs --action list_categories
95
+ node scripts/admin.mjs --action create_category --name <NAME> --parent-id 0
96
+ node scripts/admin.mjs --action update_category --category-id <ID> --name <NAME> --parent-id 0
97
+ node scripts/admin.mjs --action assign_category --agent-id <AGENT_ID> --category-id <CATEGORY_ID>
98
+ ```
99
+
100
+ ## Guardrails
101
+
102
+ - 远端动作不要改走 HTTP
103
+ - 本地配置不要手工编辑 JSON
104
+ - 安装私聊进行中时,不要主动重启 gateway
105
+ - 只有配置已确认正确但运行态明显是旧结果时,才把 `openclaw gateway restart` 当补救
106
+
107
+ ## 参考
108
+
109
+ - [Hermes Grix Runtime](../shared/references/hermes-grix-config.md)
@@ -0,0 +1,7 @@
1
+ interface:
2
+ display_name: "Grix Admin"
3
+ short_description: "Create remote agents and bind local OpenClaw."
4
+ default_prompt: "Use $grix-admin to create a remote Grix API agent and bind it into OpenClaw."
5
+
6
+ policy:
7
+ allow_implicit_invocation: true
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { spawnSync } from "node:child_process";
6
+
7
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
8
+ const sharedCli = path.resolve(scriptDir, "../../shared/cli/grix-hermes.mjs");
9
+ const result = spawnSync(process.execPath, [sharedCli, "admin", ...process.argv.slice(2)], {
10
+ stdio: "inherit",
11
+ });
12
+ process.exit(result.status ?? 1);
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env python3
2
+ """Extract bind-local fields from JSON and forward them to bind_local.py."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from pathlib import Path
9
+ import subprocess
10
+ import sys
11
+ from typing import Any
12
+
13
+
14
+ def load_payload(args: argparse.Namespace) -> dict[str, Any]:
15
+ if args.from_file:
16
+ return json.loads(Path(args.from_file).read_text(encoding="utf-8"))
17
+ raw = sys.stdin.read().strip()
18
+ if not raw:
19
+ raise RuntimeError("No JSON input provided.")
20
+ return json.loads(raw)
21
+
22
+
23
+ def clean_text(value: Any) -> str:
24
+ return str(value or "").strip()
25
+
26
+
27
+ def as_record(value: Any) -> dict[str, Any]:
28
+ return value if isinstance(value, dict) else {}
29
+
30
+
31
+ def extract_bind_fields(payload: dict[str, Any]) -> dict[str, str]:
32
+ handoff = as_record(payload.get("handoff"))
33
+ bind_local = as_record(handoff.get("bind_local"))
34
+ if bind_local:
35
+ return {
36
+ "agent_name": clean_text(bind_local.get("agent_name")),
37
+ "agent_id": clean_text(bind_local.get("agent_id")),
38
+ "api_endpoint": clean_text(bind_local.get("api_endpoint")),
39
+ "api_key": clean_text(bind_local.get("api_key")),
40
+ }
41
+
42
+ created_agent = as_record(payload.get("createdAgent"))
43
+ if created_agent:
44
+ return {
45
+ "agent_name": clean_text(created_agent.get("agent_name") or created_agent.get("name")),
46
+ "agent_id": clean_text(created_agent.get("id") or created_agent.get("agent_id")),
47
+ "api_endpoint": clean_text(created_agent.get("api_endpoint") or payload.get("api_endpoint")),
48
+ "api_key": clean_text(created_agent.get("api_key") or payload.get("api_key")),
49
+ }
50
+
51
+ return {
52
+ "agent_name": clean_text(payload.get("agent_name") or payload.get("name")),
53
+ "agent_id": clean_text(payload.get("agent_id") or payload.get("id")),
54
+ "api_endpoint": clean_text(payload.get("api_endpoint")),
55
+ "api_key": clean_text(payload.get("api_key")),
56
+ }
57
+
58
+
59
+ def main() -> int:
60
+ parser = argparse.ArgumentParser(description="Read agent JSON and forward it to bind_local.py.")
61
+ parser.add_argument("--from-file", default="")
62
+ parser.add_argument("--model", default="")
63
+ parser.add_argument("--openclaw", default="openclaw")
64
+ parser.add_argument("--openclaw-home", default="")
65
+ parser.add_argument("--skip-current", action="store_true")
66
+ parser.add_argument("--dry-run", action="store_true")
67
+ parser.add_argument("--json", action="store_true")
68
+ args = parser.parse_args()
69
+
70
+ try:
71
+ payload = load_payload(args)
72
+ bind_fields = extract_bind_fields(payload)
73
+ missing = [key for key, value in bind_fields.items() if not value]
74
+ if missing:
75
+ raise RuntimeError(f"Missing bind-local fields: {', '.join(missing)}")
76
+ bind_script = Path(__file__).with_name("bind_local.py")
77
+ cmd = [
78
+ sys.executable,
79
+ str(bind_script),
80
+ "--agent-name",
81
+ bind_fields["agent_name"],
82
+ "--agent-id",
83
+ bind_fields["agent_id"],
84
+ "--api-endpoint",
85
+ bind_fields["api_endpoint"],
86
+ "--api-key",
87
+ bind_fields["api_key"],
88
+ "--openclaw",
89
+ args.openclaw,
90
+ ]
91
+ if args.model:
92
+ cmd.extend(["--model", args.model])
93
+ if args.openclaw_home:
94
+ cmd.extend(["--openclaw-home", args.openclaw_home])
95
+ if args.skip_current:
96
+ cmd.append("--skip-current")
97
+ if args.dry_run:
98
+ cmd.append("--dry-run")
99
+ if args.json:
100
+ cmd.append("--json")
101
+
102
+ result = subprocess.run(cmd, text=True, capture_output=True)
103
+ if result.returncode != 0:
104
+ raise RuntimeError((result.stderr or result.stdout or "").strip())
105
+
106
+ if result.stdout:
107
+ sys.stdout.write(result.stdout)
108
+ return 0
109
+ except Exception as exc: # noqa: BLE001
110
+ if args.json:
111
+ print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
112
+ else:
113
+ print(str(exc), file=sys.stderr)
114
+ return 1
115
+
116
+
117
+ if __name__ == "__main__":
118
+ raise SystemExit(main())
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env python3
2
+ """Bind one remote Grix API agent into local OpenClaw config."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import subprocess
11
+ import sys
12
+ from typing import Any
13
+
14
+
15
+ MINIMAL_PERSONA_FILES = {
16
+ "IDENTITY.md": "# Identity\n\nThis agent is managed by grix-hermes.\n",
17
+ "SOUL.md": "# Soul\n\nRespond clearly and keep the workflow moving.\n",
18
+ "AGENTS.md": "# Agents\n\nUse the current workspace and configured tools.\n",
19
+ }
20
+
21
+
22
+ def run_command(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
23
+ result = subprocess.run(cmd, text=True, capture_output=True)
24
+ if check and result.returncode != 0:
25
+ stderr = (result.stderr or "").strip()
26
+ stdout = (result.stdout or "").strip()
27
+ raise RuntimeError(stderr or stdout or f"command failed: {' '.join(cmd)}")
28
+ return result
29
+
30
+
31
+ def safe_config_get(openclaw_cmd: str, path: str, default: Any) -> Any:
32
+ result = subprocess.run(
33
+ [openclaw_cmd, "config", "get", path, "--json"],
34
+ text=True,
35
+ capture_output=True,
36
+ )
37
+ if result.returncode != 0:
38
+ return default
39
+ text = (result.stdout or "").strip()
40
+ if not text:
41
+ return default
42
+ try:
43
+ return json.loads(text)
44
+ except json.JSONDecodeError:
45
+ return default
46
+
47
+
48
+ def ensure_dir(path: Path) -> None:
49
+ path.mkdir(parents=True, exist_ok=True)
50
+
51
+
52
+ def ensure_minimal_persona(workspace: Path) -> list[str]:
53
+ ensure_dir(workspace)
54
+ created: list[str] = []
55
+ for file_name, content in MINIMAL_PERSONA_FILES.items():
56
+ file_path = workspace / file_name
57
+ if file_path.exists():
58
+ continue
59
+ file_path.write_text(content, encoding="utf-8")
60
+ created.append(str(file_path))
61
+ return created
62
+
63
+
64
+ def merge_tool_allowlist(current: Any) -> list[str]:
65
+ existing: list[str] = []
66
+ if isinstance(current, list):
67
+ for item in current:
68
+ text = str(item or "").strip()
69
+ if text:
70
+ existing.append(text)
71
+ if "message" not in existing:
72
+ existing.append("message")
73
+ return existing
74
+
75
+
76
+ def resolve_model(args: argparse.Namespace, agents_list: list[dict[str, Any]], openclaw_cmd: str, skip_current: bool) -> str:
77
+ explicit = str(args.model or "").strip()
78
+ if explicit:
79
+ return explicit
80
+ for entry in agents_list:
81
+ if str(entry.get("id") or "").strip() == args.agent_name:
82
+ model = str(entry.get("model") or "").strip()
83
+ if model:
84
+ return model
85
+ if skip_current:
86
+ return ""
87
+ defaults = safe_config_get(openclaw_cmd, "agents.defaults.model.primary", "")
88
+ return str(defaults or "").strip()
89
+
90
+
91
+ def build_plan(args: argparse.Namespace) -> dict[str, Any]:
92
+ openclaw_cmd = args.openclaw
93
+ skip_current = bool(args.skip_current)
94
+ current_accounts = {} if skip_current else safe_config_get(openclaw_cmd, "channels.grix.accounts", {})
95
+ current_agents = [] if skip_current else safe_config_get(openclaw_cmd, "agents.list", [])
96
+ current_profile = "coding" if skip_current else safe_config_get(openclaw_cmd, "tools.profile", "coding")
97
+ current_allow = [] if skip_current else safe_config_get(openclaw_cmd, "tools.alsoAllow", [])
98
+ current_visibility = "agent" if skip_current else safe_config_get(openclaw_cmd, "tools.sessions.visibility", "agent")
99
+ current_grix_enabled = True if skip_current else safe_config_get(openclaw_cmd, "channels.grix.enabled", True)
100
+
101
+ openclaw_root = Path(os.path.expanduser(args.openclaw_home or "~/.openclaw"))
102
+ workspace = openclaw_root / f"workspace-{args.agent_name}"
103
+ agent_dir = openclaw_root / "agents" / args.agent_name / "agent"
104
+ model = resolve_model(args, current_agents if isinstance(current_agents, list) else [], openclaw_cmd, skip_current)
105
+ if not model:
106
+ raise RuntimeError("Missing model. Pass --model or ensure agents.defaults.model.primary exists.")
107
+
108
+ account_json = {
109
+ "name": args.agent_name,
110
+ "enabled": True,
111
+ "apiKey": args.api_key,
112
+ "wsUrl": args.api_endpoint,
113
+ "agentId": args.agent_id,
114
+ }
115
+
116
+ next_agents = []
117
+ replaced = False
118
+ for entry in current_agents if isinstance(current_agents, list) else []:
119
+ if str(entry.get("id") or "").strip() != args.agent_name:
120
+ next_agents.append(entry)
121
+ continue
122
+ replaced = True
123
+ updated = dict(entry)
124
+ updated["id"] = args.agent_name
125
+ updated["name"] = args.agent_name
126
+ updated["workspace"] = str(workspace)
127
+ updated["agentDir"] = str(agent_dir)
128
+ updated["model"] = model
129
+ next_agents.append(updated)
130
+ if not replaced:
131
+ next_agents.append(
132
+ {
133
+ "id": args.agent_name,
134
+ "name": args.agent_name,
135
+ "workspace": str(workspace),
136
+ "agentDir": str(agent_dir),
137
+ "model": model,
138
+ }
139
+ )
140
+
141
+ commands: list[list[str]] = [
142
+ [openclaw_cmd, "config", "set", f"channels.grix.accounts.{args.agent_name}", json.dumps(account_json, ensure_ascii=False), "--strict-json"],
143
+ [openclaw_cmd, "config", "set", "agents.list", json.dumps(next_agents, ensure_ascii=False), "--strict-json"],
144
+ [openclaw_cmd, "agents", "bind", "--agent", args.agent_name, "--bind", f"grix:{args.agent_name}", "--json"],
145
+ [openclaw_cmd, "config", "set", "tools.profile", json.dumps(str(current_profile or "coding")), "--strict-json"],
146
+ [openclaw_cmd, "config", "set", "tools.alsoAllow", json.dumps(merge_tool_allowlist(current_allow), ensure_ascii=False), "--strict-json"],
147
+ [openclaw_cmd, "config", "set", "tools.sessions.visibility", json.dumps(str(current_visibility or "agent")), "--strict-json"],
148
+ ]
149
+ if current_grix_enabled is False:
150
+ commands.append([openclaw_cmd, "config", "set", "channels.grix.enabled", "true", "--strict-json"])
151
+ commands.extend(
152
+ [
153
+ [openclaw_cmd, "config", "validate"],
154
+ [openclaw_cmd, "config", "get", f"channels.grix.accounts.{args.agent_name}", "--json"],
155
+ [openclaw_cmd, "config", "get", "agents.list", "--json"],
156
+ [openclaw_cmd, "agents", "bindings", "--agent", args.agent_name, "--json"],
157
+ ]
158
+ )
159
+
160
+ return {
161
+ "agent_name": args.agent_name,
162
+ "agent_id": args.agent_id,
163
+ "workspace": str(workspace),
164
+ "agent_dir": str(agent_dir),
165
+ "model": model,
166
+ "account": account_json,
167
+ "agents_list": next_agents,
168
+ "commands": commands,
169
+ }
170
+
171
+
172
+ def main() -> int:
173
+ parser = argparse.ArgumentParser(description="Bind one Grix API agent into local OpenClaw config.")
174
+ parser.add_argument("--agent-name", required=True)
175
+ parser.add_argument("--agent-id", required=True)
176
+ parser.add_argument("--api-endpoint", required=True)
177
+ parser.add_argument("--api-key", required=True)
178
+ parser.add_argument("--model", default="")
179
+ parser.add_argument("--openclaw", default="openclaw")
180
+ parser.add_argument("--openclaw-home", default="")
181
+ parser.add_argument("--skip-current", action="store_true", help="Do not read current OpenClaw config before building the plan.")
182
+ parser.add_argument("--dry-run", action="store_true", help="Only print the planned result and commands.")
183
+ parser.add_argument("--json", action="store_true", help="Emit JSON summary.")
184
+ args = parser.parse_args()
185
+
186
+ try:
187
+ plan = build_plan(args)
188
+ created_files: list[str] = []
189
+ command_results: list[dict[str, Any]] = []
190
+ if not args.dry_run:
191
+ created_files = ensure_minimal_persona(Path(plan["workspace"]))
192
+ ensure_dir(Path(plan["agent_dir"]))
193
+ for cmd in plan["commands"]:
194
+ result = run_command(cmd)
195
+ command_results.append(
196
+ {
197
+ "cmd": cmd,
198
+ "stdout": (result.stdout or "").strip(),
199
+ "stderr": (result.stderr or "").strip(),
200
+ }
201
+ )
202
+ payload = {
203
+ "ok": True,
204
+ "dry_run": bool(args.dry_run),
205
+ "created_files": created_files,
206
+ "command_results": command_results,
207
+ **plan,
208
+ }
209
+ if args.json:
210
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
211
+ else:
212
+ print(f"agent={plan['agent_name']} workspace={plan['workspace']} dry_run={args.dry_run}")
213
+ for cmd in plan["commands"]:
214
+ print("$ " + " ".join(cmd))
215
+ return 0
216
+ except Exception as exc: # noqa: BLE001
217
+ payload = {"ok": False, "error": str(exc)}
218
+ if args.json:
219
+ print(json.dumps(payload, ensure_ascii=False, indent=2), file=sys.stderr)
220
+ else:
221
+ print(str(exc), file=sys.stderr)
222
+ return 1
223
+
224
+
225
+ if __name__ == "__main__":
226
+ raise SystemExit(main())