agent-comm-hub 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -84,6 +84,7 @@ Task Scheduler on Windows) — or the built-in one-shot auto-start:
84
84
  ```bash
85
85
  agent-comm-hub service install # Windows: HKCU Run + hidden launcher (no admin)
86
86
  # Linux: systemd --user unit, enabled
87
+ # macOS: launchd LaunchAgent
87
88
  agent-comm-hub service uninstall # undo
88
89
  agent-comm-hub status # is the hub up? who is online?
89
90
  ```
@@ -98,13 +99,19 @@ itself).
98
99
  agent-comm-hub setup
99
100
  # or: agents/install-all.ps1 (PowerShell equivalent)
100
101
  # undo: agent-comm-hub setup --remove
102
+ # list what is installed (no changes): agent-comm-hub discover
103
+ # configure a single agent: agent-comm-hub setup --agent codex
101
104
  ```
102
105
 
103
- `setup` incrementally merges the `agent-hub` MCP entry into every installed
104
- agent's own config (mcode, opencode, Kimi Code, Gemini CLI, Codex, zcode, DSH)
105
- and installs the English skill into `~/.agents/skills/` (the cross-agent
106
- standard) plus each agent's private skills dir. Only the `agent-hub` key is
107
- touched, every file is backed up first, and re-running is a no-op. Claude Code
106
+ `setup` **discovers** which agents are installed on this machine (PATH
107
+ commands, config paths, npm global packages no shell, cross-platform) and
108
+ merges the `agent-hub` MCP entry into each one's config (mcode, opencode, Kimi
109
+ Code, Gemini CLI, Codex, zcode, DSH, claude-desktop on macOS), plus the English
110
+ skill into `~/.agents/skills/` (the cross-agent standard) and each agent's
111
+ private skills dir. Which agents are supported is declared in
112
+ [`agents/registry.json`](agents/registry.json) — adding a new agent is one
113
+ registry record, no code change. Only the `agent-hub` key is touched, every
114
+ file is backed up first, and re-running is a no-op. Claude Code's MCP config
108
115
  stays manual (see below).
109
116
 
110
117
  **Registration is automatic**: once an agent session starts, the MCP handshake
package/README.zh.md CHANGED
@@ -82,6 +82,7 @@ agent-comm-hub
82
82
  ```bash
83
83
  agent-comm-hub service install # Windows:HKCU Run + 隐藏启动器(无需管理员)
84
84
  # Linux:systemd --user 单元并启用
85
+ # macOS:launchd LaunchAgent
85
86
  agent-comm-hub service uninstall # 撤销
86
87
  agent-comm-hub status # hub 是否在跑?谁在线?
87
88
  ```
@@ -94,12 +95,18 @@ agent-comm-hub status # hub 是否在跑?谁在线?
94
95
  agent-comm-hub setup
95
96
  # 或 PowerShell 版:agents/install-all.ps1
96
97
  # 卸载:agent-comm-hub setup --remove
98
+ # 查看本机装了什么(只读):agent-comm-hub discover
99
+ # 只配置某一个:agent-comm-hub setup --agent codex
97
100
  ```
98
101
 
99
- `setup` 会把 `agent-hub` MCP 条目**增量合并**进每个已安装 agent 的配置
100
- (mcode / opencode / kimi-code / gemini / codex / zcode),并把英文 SKILL 装到
101
- `~/.agents/skills/`(跨 agent 标准位置)+ agent 私有技能目录。只动
102
- `agent-hub` 这一个键、每个文件先备份、幂等可重跑。Claude Code DSH 手动(见下)。
102
+ `setup` 会**自动发现**本机安装了哪些 agent(PATH 命令 / 配置文件路径 /
103
+ npm 全局包,纯 Node shell、跨平台),并把 `agent-hub` MCP 条目
104
+ **增量合并**进每个已安装 agent 的配置(mcode / opencode / kimi-code /
105
+ gemini / codex / zcode / DSH,macOS 上还有 claude-desktop),英文 SKILL 装到
106
+ `~/.agents/skills/`(跨 agent 标准位置)+ 各 agent 私有技能目录。支持哪些
107
+ agent 由 [`agents/registry.json`](agents/registry.json) 声明——新增 agent
108
+ 只需加一条注册表记录,不改代码。只动 `agent-hub` 这一个键、每个文件先备份、
109
+ 幂等可重跑。Claude Code 的 MCP 配置手动(见下)。
103
110
 
104
111
  **注册全自动**:agent 会话一启动,MCP 握手即完成注册(客户端名 = peer id),无需任何手动操作。可选:`bridge_register("工具名:项目名")` 换可读 id。
105
112
 
@@ -0,0 +1,201 @@
1
+ {
2
+ "$schema": "docs/agent-registry.schema.json",
3
+ "agents": [
4
+ {
5
+ "id": "mcode",
6
+ "probe": [],
7
+ "configs": [
8
+ {
9
+ "file": "~/.minimax/mcp.json",
10
+ "section": "mcpServers",
11
+ "strategy": "json",
12
+ "entry": {
13
+ "url": "{url}",
14
+ "type": "streamable-http",
15
+ "enabled": true,
16
+ "configured": true,
17
+ "timeout": 120000,
18
+ "description": "agent-comm-hub: talk to every other agent connected to the hub."
19
+ }
20
+ },
21
+ {
22
+ "file": "~/.minimax/mcp/mcp.json",
23
+ "section": "mcpServers",
24
+ "strategy": "json",
25
+ "entry": {
26
+ "url": "{url}",
27
+ "type": "streamable-http",
28
+ "enabled": true,
29
+ "configured": true,
30
+ "timeout": 120000,
31
+ "description": "agent-comm-hub: talk to every other agent connected to the hub."
32
+ }
33
+ }
34
+ ],
35
+ "skill": "~/.minimax/skills",
36
+ "os": ["win32", "darwin", "linux"]
37
+ },
38
+ {
39
+ "id": "opencode",
40
+ "probe": ["opencode"],
41
+ "npm": ["cli"],
42
+ "configs": [
43
+ {
44
+ "file": "~/.config/opencode/opencode.json",
45
+ "section": "mcp",
46
+ "strategy": "json",
47
+ "entry": { "type": "remote", "url": "{url}", "enabled": true }
48
+ }
49
+ ],
50
+ "skill": "~/.config/opencode/skills",
51
+ "os": ["win32", "darwin", "linux"]
52
+ },
53
+ {
54
+ "id": "kimi-code",
55
+ "probe": ["kimi"],
56
+ "configs": [
57
+ {
58
+ "file": "~/.kimi-code/mcp.json",
59
+ "section": "mcpServers",
60
+ "strategy": "json",
61
+ "entry": { "transport": "http", "url": "{url}", "startupTimeoutMs": 30000, "toolTimeoutMs": 120000 }
62
+ }
63
+ ],
64
+ "skill": "~/.kimi-code/skills",
65
+ "os": ["win32", "darwin", "linux"]
66
+ },
67
+ {
68
+ "id": "gemini-cli",
69
+ "probe": ["gemini"],
70
+ "configs": [
71
+ {
72
+ "file": "~/.gemini/settings.json",
73
+ "section": "mcpServers",
74
+ "strategy": "json",
75
+ "entry": { "type": "http", "url": "{url}" }
76
+ }
77
+ ],
78
+ "skill": "~/.gemini/skills",
79
+ "os": ["win32", "darwin", "linux"]
80
+ },
81
+ {
82
+ "id": "codex",
83
+ "probe": ["codex"],
84
+ "npm": ["codex"],
85
+ "configs": [
86
+ {
87
+ "file": "~/.codex/config.toml",
88
+ "section": null,
89
+ "strategy": "toml",
90
+ "entry": null
91
+ }
92
+ ],
93
+ "skill": "~/.codex/skills",
94
+ "os": ["win32", "darwin", "linux"]
95
+ },
96
+ {
97
+ "id": "zcode",
98
+ "probe": [],
99
+ "configs": [
100
+ {
101
+ "file": "~/.zcode/cli/config.json",
102
+ "section": "mcp.servers",
103
+ "strategy": "json",
104
+ "entry": { "type": "remote", "url": "{url}", "enabled": true }
105
+ }
106
+ ],
107
+ "skill": "~/.zcode/skills",
108
+ "os": ["win32", "darwin", "linux"]
109
+ },
110
+ {
111
+ "id": "dsh",
112
+ "probe": [],
113
+ "configs": [
114
+ {
115
+ "file": "~/.dsh/profiles/*/cordis.patch.yml",
116
+ "section": null,
117
+ "strategy": "dsh",
118
+ "entry": null
119
+ }
120
+ ],
121
+ "skill": "~/.dsh/skills",
122
+ "os": ["win32", "darwin", "linux"]
123
+ },
124
+ {
125
+ "id": "claude",
126
+ "probe": ["claude"],
127
+ "npm": ["claude-code"],
128
+ "configs": [],
129
+ "skill": "~/.claude/skills",
130
+ "os": ["win32", "darwin", "linux"]
131
+ },
132
+ {
133
+ "id": "qwen-code",
134
+ "probe": ["qwen"],
135
+ "npm": ["qwen-code"],
136
+ "configs": [
137
+ {
138
+ "file": "~/.qwen/settings.json",
139
+ "section": "mcpServers",
140
+ "strategy": "json",
141
+ "entry": { "httpUrl": "{url}" }
142
+ }
143
+ ],
144
+ "skill": null,
145
+ "os": ["win32", "darwin", "linux"]
146
+ },
147
+ {
148
+ "id": "pi",
149
+ "probe": ["pi"],
150
+ "npm": ["pi-coding-agent"],
151
+ "configs": [],
152
+ "skill": null,
153
+ "os": ["win32", "darwin", "linux"]
154
+ },
155
+ {
156
+ "id": "cursor",
157
+ "probe": ["cursor", "cursor-agent"],
158
+ "npm": ["cursor"],
159
+ "configs": [
160
+ {
161
+ "file": "~/.cursor/mcp.json",
162
+ "section": "mcpServers",
163
+ "strategy": "json",
164
+ "entry": { "url": "{url}" }
165
+ }
166
+ ],
167
+ "skill": null,
168
+ "os": ["win32", "darwin", "linux"]
169
+ },
170
+ {
171
+ "id": "qoder",
172
+ "probe": ["qoder"],
173
+ "npm": ["qoder"],
174
+ "configs": [],
175
+ "skill": null,
176
+ "os": ["win32", "darwin", "linux"]
177
+ },
178
+ {
179
+ "id": "codebuddy",
180
+ "probe": ["codebuddy"],
181
+ "npm": ["codebuddy"],
182
+ "configs": [],
183
+ "skill": null,
184
+ "os": ["win32", "darwin", "linux"]
185
+ },
186
+ {
187
+ "id": "claude-desktop",
188
+ "probe": [],
189
+ "configs": [
190
+ {
191
+ "file": "~/Library/Application Support/Claude/claude_desktop_config.json",
192
+ "section": "mcpServers",
193
+ "strategy": "json",
194
+ "entry": { "url": "{url}", "type": "streamable-http" }
195
+ }
196
+ ],
197
+ "skill": null,
198
+ "os": ["darwin"]
199
+ }
200
+ ]
201
+ }
package/lib/cli.js CHANGED
@@ -1334,7 +1334,7 @@ function readBody(req) {
1334
1334
 
1335
1335
  // src/index.ts
1336
1336
  var SERVER_NAME = "agent-comm-hub";
1337
- var SERVER_VERSION = "0.3.0";
1337
+ var SERVER_VERSION = "0.4.0";
1338
1338
  var DEFAULT_HOST = "127.0.0.1";
1339
1339
  var DEFAULT_PORT = 18764;
1340
1340
  var DEFAULT_PATH = "/mcp";
@@ -1410,15 +1410,190 @@ function startHub(config = {}, log2 = console) {
1410
1410
  }
1411
1411
 
1412
1412
  // src/setup.ts
1413
- import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
1414
- import { existsSync } from "node:fs";
1413
+ import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
1414
+ import { existsSync as existsSync2 } from "node:fs";
1415
+ import { homedir as homedir2 } from "node:os";
1416
+ import { dirname as dirname2, join as join2 } from "node:path";
1417
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1418
+
1419
+ // src/discover.ts
1420
+ import { execFileSync } from "node:child_process";
1421
+ import { existsSync, readdirSync, accessSync, readFileSync, constants as fsConstants } from "node:fs";
1415
1422
  import { homedir } from "node:os";
1416
- import { dirname, join } from "node:path";
1423
+ import { dirname, join, sep } from "node:path";
1417
1424
  import { fileURLToPath } from "node:url";
1425
+ function registryFile() {
1426
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
1427
+ }
1428
+ function expandHome(file, home) {
1429
+ return file.startsWith("~/") ? join(home, file.slice(2)) : file;
1430
+ }
1431
+ function expandConfigFile(file, home) {
1432
+ const expanded = expandHome(file, home);
1433
+ const star = expanded.indexOf("*");
1434
+ if (star < 0) return [expanded];
1435
+ const prefix = expanded.slice(0, star);
1436
+ const suffix = expanded.slice(star + 1);
1437
+ const base = prefix.slice(0, prefix.lastIndexOf(sep));
1438
+ if (!existsSync(base)) return [];
1439
+ return readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(base, entry.name, suffix));
1440
+ }
1441
+ function validateRegistry(registry) {
1442
+ if (!Array.isArray(registry.agents)) throw new Error('registry: missing "agents" array');
1443
+ const ids = /* @__PURE__ */ new Set();
1444
+ for (const agent of registry.agents) {
1445
+ if (typeof agent.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(agent.id)) {
1446
+ throw new Error(`registry: bad agent id ${JSON.stringify(agent.id)}`);
1447
+ }
1448
+ if (ids.has(agent.id)) throw new Error(`registry: duplicate agent id '${agent.id}'`);
1449
+ ids.add(agent.id);
1450
+ if (!Array.isArray(agent.probe)) throw new Error(`registry: ${agent.id}: probe must be an array`);
1451
+ if (agent.npm !== void 0 && !Array.isArray(agent.npm)) throw new Error(`registry: ${agent.id}: npm must be an array`);
1452
+ if (!Array.isArray(agent.configs)) throw new Error(`registry: ${agent.id}: configs must be an array`);
1453
+ for (const config of agent.configs) {
1454
+ if (typeof config.file !== "string" || !config.file.startsWith("~/")) {
1455
+ throw new Error(`registry: ${agent.id}: config file must be '~'-relative`);
1456
+ }
1457
+ if (config.file.split("/").includes("..")) {
1458
+ throw new Error(`registry: ${agent.id}: config file must not contain '..'`);
1459
+ }
1460
+ if (config.file.split("*").length > 2) {
1461
+ throw new Error(`registry: ${agent.id}: at most one '*' segment allowed`);
1462
+ }
1463
+ if (!["json", "toml", "dsh"].includes(config.strategy)) {
1464
+ throw new Error(`registry: ${agent.id}: unknown strategy '${config.strategy}'`);
1465
+ }
1466
+ if (config.strategy === "json" && (typeof config.section !== "string" || config.entry === null)) {
1467
+ throw new Error(`registry: ${agent.id}: json strategy needs a section and an entry`);
1468
+ }
1469
+ if (config.strategy !== "json" && config.entry !== null) {
1470
+ throw new Error(`registry: ${agent.id}: only json strategy may carry an entry`);
1471
+ }
1472
+ }
1473
+ if (agent.skill !== null && (typeof agent.skill !== "string" || !agent.skill.startsWith("~/"))) {
1474
+ throw new Error(`registry: ${agent.id}: skill must be '~'-relative or null`);
1475
+ }
1476
+ if (agent.os !== void 0 && (!Array.isArray(agent.os) || agent.os.some((os2) => !["win32", "darwin", "linux"].includes(os2)))) {
1477
+ throw new Error(`registry: ${agent.id}: invalid os list`);
1478
+ }
1479
+ }
1480
+ }
1481
+ function loadRegistry(file = registryFile()) {
1482
+ const registry = JSON.parse(readFileSync(file, "utf8"));
1483
+ validateRegistry(registry);
1484
+ return registry;
1485
+ }
1486
+ function commandOnPath(command, pathEnv, pathext, platform) {
1487
+ const dirs = pathEnv.split(platform === "win32" ? ";" : ":");
1488
+ const extensions = platform === "win32" ? ["", ...(pathext || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""];
1489
+ for (const dir of dirs) {
1490
+ const base = dir === "" ? "." : dir;
1491
+ for (const ext of extensions) {
1492
+ const candidate = join(base, command + ext);
1493
+ try {
1494
+ accessSync(candidate, fsConstants.X_OK);
1495
+ return true;
1496
+ } catch {
1497
+ }
1498
+ }
1499
+ }
1500
+ return false;
1501
+ }
1502
+ function readNpmNames(root) {
1503
+ const names = [];
1504
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
1505
+ if (!entry.isDirectory()) continue;
1506
+ if (entry.name.startsWith("@")) {
1507
+ const scopeDir = join(root, entry.name);
1508
+ for (const sub of readdirSync(scopeDir, { withFileTypes: true })) {
1509
+ if (sub.isDirectory()) names.push(sub.name);
1510
+ }
1511
+ } else {
1512
+ names.push(entry.name);
1513
+ }
1514
+ }
1515
+ return names;
1516
+ }
1517
+ function npmGlobalRoot(platform) {
1518
+ try {
1519
+ const out = execFileSync(platform === "win32" ? "npm.cmd" : "npm", ["root", "-g"], { encoding: "utf8", windowsHide: true }).trim();
1520
+ return out || null;
1521
+ } catch {
1522
+ return null;
1523
+ }
1524
+ }
1525
+ function npmFallbackRoots(home, platform) {
1526
+ if (platform === "win32") {
1527
+ const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
1528
+ return [join(appData, "npm", "node_modules")];
1529
+ }
1530
+ return [
1531
+ "/usr/local/lib/node_modules",
1532
+ "/usr/lib/node_modules",
1533
+ ...existsSync(join(home, ".nvm", "versions", "node")) ? readdirSync(join(home, ".nvm", "versions", "node")).map((dir) => join(home, ".nvm", "versions", "node", dir, "lib", "node_modules")) : []
1534
+ ];
1535
+ }
1536
+ function discover(registry, options = {}) {
1537
+ const home = options.homeDir ?? homedir();
1538
+ const platform = options.platform ?? process.platform;
1539
+ const pathEnv = options.pathEnv ?? process.env.PATH ?? "";
1540
+ const pathext = options.pathext ?? process.env.PATHEXT ?? "";
1541
+ let npmNames = null;
1542
+ if (!options.noNpm) {
1543
+ if (options.npmRoot !== void 0 && options.npmRoot !== null) {
1544
+ npmNames = existsSync(options.npmRoot) ? readNpmNames(options.npmRoot) : [];
1545
+ } else if (options.npmRoot !== null) {
1546
+ const root = npmGlobalRoot(platform);
1547
+ if (root && existsSync(root)) {
1548
+ npmNames = readNpmNames(root);
1549
+ } else {
1550
+ npmNames = [];
1551
+ for (const fallback of npmFallbackRoots(home, platform)) {
1552
+ if (existsSync(fallback)) {
1553
+ npmNames = readNpmNames(fallback);
1554
+ break;
1555
+ }
1556
+ }
1557
+ }
1558
+ }
1559
+ }
1560
+ return registry.agents.filter((agent) => agent.os === void 0 || agent.os.includes(platform)).map((agent) => {
1561
+ let source = "none";
1562
+ const configFiles = [];
1563
+ for (const config of agent.configs) {
1564
+ for (const file of expandConfigFile(config.file, home)) {
1565
+ if (existsSync(file)) {
1566
+ configFiles.push(file);
1567
+ if (source === "none") source = "config";
1568
+ }
1569
+ }
1570
+ }
1571
+ if (source === "none" && agent.probe.some((command) => commandOnPath(command, pathEnv, pathext, platform))) {
1572
+ source = "path";
1573
+ }
1574
+ if (source === "none" && npmNames !== null && (agent.probe.some((command) => npmNames.includes(command)) || (agent.npm ?? []).some((name) => npmNames.includes(name)))) {
1575
+ source = "npm";
1576
+ }
1577
+ return { id: agent.id, source, configFiles, present: source !== "none" };
1578
+ });
1579
+ }
1580
+ function runDiscover(options = {}) {
1581
+ const log2 = options.log ?? ((message) => console.log(message));
1582
+ const found = discover(loadRegistry(), options);
1583
+ log2("discovered agents:");
1584
+ for (const agent of found) {
1585
+ const status = agent.present ? agent.source : "not installed";
1586
+ const files = agent.configFiles.length > 0 ? ` \u2014 ${agent.configFiles.join(", ")}` : "";
1587
+ log2(` ${agent.id.padEnd(16)} ${status}${files}`);
1588
+ }
1589
+ return found;
1590
+ }
1591
+
1592
+ // src/setup.ts
1418
1593
  var DEFAULT_URL = "http://127.0.0.1:18764/mcp";
1419
1594
  var DEFAULT_SERVER = "agent-hub";
1420
1595
  function defaultSkillSrc() {
1421
- return join(dirname(fileURLToPath(import.meta.url)), "..", "agents", "SKILL.md");
1596
+ return join2(dirname2(fileURLToPath2(import.meta.url)), "..", "agents", "SKILL.md");
1422
1597
  }
1423
1598
  function stamp() {
1424
1599
  const now = /* @__PURE__ */ new Date();
@@ -1426,7 +1601,7 @@ function stamp() {
1426
1601
  return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
1427
1602
  }
1428
1603
  async function readJson(file) {
1429
- if (!existsSync(file)) return null;
1604
+ if (!existsSync2(file)) return null;
1430
1605
  try {
1431
1606
  return JSON.parse(await readFile(file, "utf8"));
1432
1607
  } catch (error) {
@@ -1434,7 +1609,7 @@ async function readJson(file) {
1434
1609
  }
1435
1610
  }
1436
1611
  async function writeJsonNoBom(file, doc) {
1437
- await mkdir(dirname(file), { recursive: true });
1612
+ await mkdir(dirname2(file), { recursive: true });
1438
1613
  await writeFile(file, JSON.stringify(doc, null, 2) + "\n", "utf8");
1439
1614
  }
1440
1615
  async function backup(file) {
@@ -1455,7 +1630,7 @@ function resolveSection(doc, section) {
1455
1630
  return node;
1456
1631
  }
1457
1632
  async function mergeJsonServer(file, section, entry, opts) {
1458
- if (!existsSync(file)) return "skipped";
1633
+ if (!existsSync2(file)) return "skipped";
1459
1634
  const doc = await readJson(file);
1460
1635
  if (doc === null) return "skipped";
1461
1636
  const servers = resolveSection(doc, section);
@@ -1477,7 +1652,7 @@ async function mergeJsonServer(file, section, entry, opts) {
1477
1652
  return "changed";
1478
1653
  }
1479
1654
  async function mergeTomlSection(file, opts) {
1480
- if (!existsSync(file)) return "skipped";
1655
+ if (!existsSync2(file)) return "skipped";
1481
1656
  const text = await readFile(file, "utf8");
1482
1657
  const marker = `[mcp_servers.${opts.serverName}]`;
1483
1658
  const markerRe = new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\]`, "m");
@@ -1515,7 +1690,7 @@ ${DSH_PATCH_MARKER} (installed by \`agent-comm-hub setup\`; undo with \`setup --
1515
1690
  `;
1516
1691
  }
1517
1692
  async function mergeDshPatch(file, opts) {
1518
- if (!existsSync(file)) return "skipped";
1693
+ if (!existsSync2(file)) return "skipped";
1519
1694
  const text = await readFile(file, "utf8");
1520
1695
  const lines = text.split("\n");
1521
1696
  const markerLine = lines.findIndex((line) => line.includes(DSH_PATCH_MARKER));
@@ -1565,29 +1740,40 @@ async function mergeDshPatch(file, opts) {
1565
1740
  }
1566
1741
  async function syncSkill(skillDir, skillSrc, remove, log2) {
1567
1742
  if (remove) {
1568
- if (existsSync(skillDir)) {
1569
- await mkdir(dirname(skillDir), { recursive: true });
1743
+ if (existsSync2(skillDir)) {
1744
+ await mkdir(dirname2(skillDir), { recursive: true });
1570
1745
  await rmRecursive(skillDir);
1571
1746
  log2(` skill removed: ${skillDir}`);
1572
1747
  }
1573
1748
  return;
1574
1749
  }
1575
- if (!existsSync(skillSrc)) {
1750
+ if (!existsSync2(skillSrc)) {
1576
1751
  log2(` SKILL.md source missing: ${skillSrc} (skipped)`);
1577
1752
  return;
1578
1753
  }
1579
1754
  await mkdir(skillDir, { recursive: true });
1580
- await copyFile(skillSrc, join(skillDir, "SKILL.md"));
1581
- log2(` skill -> ${join(skillDir, "SKILL.md")}`);
1755
+ await copyFile(skillSrc, join2(skillDir, "SKILL.md"));
1756
+ log2(` skill -> ${join2(skillDir, "SKILL.md")}`);
1582
1757
  }
1583
1758
  async function rmRecursive(dir) {
1584
1759
  const { rm } = await import("node:fs/promises");
1585
1760
  await rm(dir, { recursive: true, force: true });
1586
1761
  }
1762
+ function substitute(entry, values) {
1763
+ const out = {};
1764
+ for (const [key, value] of Object.entries(entry)) {
1765
+ if (typeof value === "string") {
1766
+ out[key] = value.replaceAll("{url}", values.url).replaceAll("{serverName}", values.serverName);
1767
+ } else {
1768
+ out[key] = value;
1769
+ }
1770
+ }
1771
+ return out;
1772
+ }
1587
1773
  async function runSetup(options = {}) {
1588
1774
  const url = options.url ?? DEFAULT_URL;
1589
1775
  const serverName = options.serverName ?? DEFAULT_SERVER;
1590
- const home = options.homeDir ?? homedir();
1776
+ const home = options.homeDir ?? homedir2();
1591
1777
  const skillSrc = options.skillSrc ?? defaultSkillSrc();
1592
1778
  const remove = options.remove === true;
1593
1779
  const log2 = options.log ?? ((message) => console.log(message));
@@ -1597,65 +1783,40 @@ async function runSetup(options = {}) {
1597
1783
  else if (status === "unchanged" || status === "absent") summary.unchanged.push(`${label}: ${file}`);
1598
1784
  else if (status === "skipped") summary.skipped.push(`${label}: ${file}`);
1599
1785
  };
1600
- const jsonTargets = [
1601
- { label: "mcode", file: join(home, ".minimax", "mcp.json"), section: "mcpServers", entry: { url, type: "streamable-http", enabled: true, configured: true, timeout: 12e4, description: "agent-comm-hub: talk to every other agent connected to the hub." } },
1602
- { label: "mcode", file: join(home, ".minimax", "mcp", "mcp.json"), section: "mcpServers", entry: { url, type: "streamable-http", enabled: true, configured: true, timeout: 12e4, description: "agent-comm-hub: talk to every other agent connected to the hub." } },
1603
- { label: "opencode", file: join(home, ".config", "opencode", "opencode.json"), section: "mcp", entry: { type: "remote", url, enabled: true } },
1604
- { label: "kimi-code", file: join(home, ".kimi-code", "mcp.json"), section: "mcpServers", entry: { transport: "http", url, startupTimeoutMs: 3e4, toolTimeoutMs: 12e4 } },
1605
- { label: "gemini-cli", file: join(home, ".gemini", "settings.json"), section: "mcpServers", entry: { type: "http", url } },
1606
- { label: "zcode", file: join(home, ".zcode", "cli", "config.json"), section: "mcp.servers", entry: { type: "remote", url, enabled: true } }
1607
- ];
1608
- for (const target of jsonTargets) {
1609
- try {
1610
- const status = await mergeJsonServer(target.file, target.section, target.entry, { serverName, url, remove });
1611
- record(status, target.label, target.file);
1612
- } catch (error) {
1613
- summary.errors.push(`${target.label}: ${target.file} \u2014 ${error.message}`);
1614
- log2(` ${target.label}: SKIPPED \u2014 ${error.message}`);
1615
- }
1616
- }
1617
- const codexFile = join(home, ".codex", "config.toml");
1618
- try {
1619
- const status = await mergeTomlSection(codexFile, { serverName, url, remove });
1620
- record(status, "codex", codexFile);
1621
- } catch (error) {
1622
- summary.errors.push(`codex: ${codexFile} \u2014 ${error.message}`);
1623
- log2(` codex: SKIPPED \u2014 ${error.message}`);
1624
- }
1625
- const dshProfilesDir = join(home, ".dsh", "profiles");
1626
- if (existsSync(dshProfilesDir)) {
1627
- let entries = [];
1628
- try {
1629
- entries = await readdir(dshProfilesDir, { withFileTypes: true });
1630
- } catch (error) {
1631
- summary.errors.push(`dsh profiles scan \u2014 ${error.message}`);
1786
+ const registry = loadRegistry();
1787
+ const found = discover(registry, { homeDir: home, pathEnv: options.pathEnv, noNpm: options.noNpm === true });
1788
+ const only = options.agent;
1789
+ let targetAgents = [];
1790
+ if (only !== void 0) {
1791
+ const match = registry.agents.find((agent) => agent.id === only);
1792
+ if (match === void 0) {
1793
+ log2(`agent '${only}' is not in the registry (see agents/registry.json)`);
1794
+ } else {
1795
+ targetAgents = [match];
1796
+ log2(`configure only: ${only}`);
1632
1797
  }
1633
- for (const entry of entries) {
1634
- if (!entry.isDirectory()) continue;
1635
- const patch = join(dshProfilesDir, entry.name, "cordis.patch.yml");
1636
- try {
1637
- const status = await mergeDshPatch(patch, { serverName, url, remove });
1638
- record(status, `dsh (${entry.name})`, patch);
1639
- } catch (error) {
1640
- summary.errors.push(`dsh (${entry.name}): ${patch} \u2014 ${error.message}`);
1641
- log2(` dsh (${entry.name}): SKIPPED \u2014 ${error.message}`);
1798
+ } else {
1799
+ targetAgents = found.filter((agent) => agent.present).map((agent) => registry.agents.find((entry) => entry.id === agent.id));
1800
+ const present2 = targetAgents.map((agent) => agent.id);
1801
+ log2(`discovered: ${present2.length > 0 ? present2.join(", ") : "none"}`);
1802
+ }
1803
+ for (const agent of targetAgents) {
1804
+ for (const config of agent.configs) {
1805
+ for (const file of expandConfigFile(config.file, home)) {
1806
+ try {
1807
+ const status = config.strategy === "json" ? await mergeJsonServer(file, config.section, substitute(config.entry, { url, serverName }), { serverName, url, remove }) : config.strategy === "toml" ? await mergeTomlSection(file, { serverName, url, remove }) : await mergeDshPatch(file, { serverName, url, remove });
1808
+ record(status, agent.id, file);
1809
+ } catch (error) {
1810
+ summary.errors.push(`${agent.id}: ${file} \u2014 ${error.message}`);
1811
+ log2(` ${agent.id}: SKIPPED \u2014 ${error.message}`);
1812
+ }
1642
1813
  }
1643
1814
  }
1644
1815
  }
1645
- const skillDirs = [
1646
- join(home, ".agents", "skills", serverName),
1647
- // cross-agent standard
1648
- join(home, ".minimax", "skills", serverName),
1649
- join(home, ".config", "opencode", "skills", serverName),
1650
- join(home, ".kimi-code", "skills", serverName),
1651
- join(home, ".gemini", "skills", serverName),
1652
- join(home, ".codex", "skills", serverName),
1653
- join(home, ".zcode", "skills", serverName),
1654
- join(home, ".claude", "skills", serverName),
1655
- // config is manual; skill still useful
1656
- join(home, ".dsh", "skills", serverName)
1657
- // DSH skill (config auto-installed)
1658
- ];
1816
+ const skillDirs = [join2(home, ".agents", "skills", serverName)];
1817
+ for (const agent of targetAgents) {
1818
+ if (agent.skill !== null) skillDirs.push(join2(expandHome(agent.skill, home), serverName));
1819
+ }
1659
1820
  for (const dir of skillDirs) {
1660
1821
  try {
1661
1822
  await syncSkill(dir, skillSrc, remove, log2);
@@ -1670,12 +1831,12 @@ async function runSetup(options = {}) {
1670
1831
  }
1671
1832
 
1672
1833
  // src/ops.ts
1673
- import { execFileSync } from "node:child_process";
1834
+ import { execFileSync as execFileSync2 } from "node:child_process";
1674
1835
  import { request } from "node:http";
1675
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1676
- import { homedir as homedir2 } from "node:os";
1677
- import { dirname as dirname2, join as join2 } from "node:path";
1678
- import { fileURLToPath as fileURLToPath2 } from "node:url";
1836
+ import { mkdirSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
1837
+ import { homedir as homedir3 } from "node:os";
1838
+ import { dirname as dirname3, join as join3 } from "node:path";
1839
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
1679
1840
  async function runStatus(options = {}) {
1680
1841
  const host = options.host ?? "127.0.0.1";
1681
1842
  const port = options.port ?? 18764;
@@ -1727,8 +1888,8 @@ async function runStatus(options = {}) {
1727
1888
  function runUpdate() {
1728
1889
  const messages = [];
1729
1890
  try {
1730
- const pkgFile = join2(dirname2(fileURLToPath2(import.meta.url)), "..", "package.json");
1731
- const before = JSON.parse(readFileSync(pkgFile, "utf8")).version ?? "?";
1891
+ const pkgFile = join3(dirname3(fileURLToPath3(import.meta.url)), "..", "package.json");
1892
+ const before = JSON.parse(readFileSync2(pkgFile, "utf8")).version ?? "?";
1732
1893
  messages.push(`current version: ${before}`);
1733
1894
  const script = [
1734
1895
  "import { execFileSync } from 'node:child_process'",
@@ -1744,7 +1905,7 @@ function runUpdate() {
1744
1905
  "if (after === before) console.log('already up to date (v' + after + ')')",
1745
1906
  "else console.log('updated: v' + before + ' -> v' + after)"
1746
1907
  ].join("\n");
1747
- const out = execFileSync(process.execPath, ["--input-type=module", "-e", script], { encoding: "utf8", windowsHide: true });
1908
+ const out = execFileSync2(process.execPath, ["--input-type=module", "-e", script], { encoding: "utf8", windowsHide: true });
1748
1909
  messages.push(out.trim());
1749
1910
  messages.push("restart the hub (agent-comm-hub) to pick up the new version");
1750
1911
  return { ok: true, messages };
@@ -1753,14 +1914,14 @@ function runUpdate() {
1753
1914
  }
1754
1915
  }
1755
1916
  function cliPath() {
1756
- return fileURLToPath2(import.meta.url);
1917
+ return fileURLToPath3(import.meta.url);
1757
1918
  }
1758
1919
  function nodeExe() {
1759
1920
  return process.execPath;
1760
1921
  }
1761
1922
  function run(command, args, dryRun) {
1762
1923
  if (dryRun) return `[dry-run] ${command} ${args.join(" ")}`;
1763
- return execFileSync(command, args, { encoding: "utf8", windowsHide: true }).trim();
1924
+ return execFileSync2(command, args, { encoding: "utf8", windowsHide: true }).trim();
1764
1925
  }
1765
1926
  function runService(options) {
1766
1927
  const messages = [];
@@ -1768,11 +1929,12 @@ function runService(options) {
1768
1929
  const host = options.host ?? "127.0.0.1";
1769
1930
  const path2 = options.path ?? "/mcp";
1770
1931
  const dryRun = options.dryRun === true;
1932
+ const platform = options.platform ?? process.platform;
1771
1933
  try {
1772
- if (process.platform === "win32") {
1773
- const appData = process.env.APPDATA ?? join2(homedir2(), "AppData", "Roaming");
1774
- const launcherDir = join2(appData, "agent-comm-hub");
1775
- const vbs = join2(launcherDir, "agent-comm-hub.vbs");
1934
+ if (platform === "win32") {
1935
+ const appData = process.env.APPDATA ?? join3(homedir3(), "AppData", "Roaming");
1936
+ const launcherDir = join3(appData, "agent-comm-hub");
1937
+ const vbs = join3(launcherDir, "agent-comm-hub.vbs");
1776
1938
  const runKey = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
1777
1939
  const valueName = "agent-comm-hub";
1778
1940
  if (options.action === "install") {
@@ -1785,7 +1947,7 @@ function runService(options) {
1785
1947
  } else {
1786
1948
  mkdirSync(launcherDir, { recursive: true });
1787
1949
  writeFileSync(vbs, vbsContent);
1788
- execFileSync("reg", ["add", runKey, "/v", valueName, "/t", "REG_SZ", "/d", `wscript.exe "${vbs}"`, "/f"], { encoding: "utf8", windowsHide: true });
1950
+ execFileSync2("reg", ["add", runKey, "/v", valueName, "/t", "REG_SZ", "/d", `wscript.exe "${vbs}"`, "/f"], { encoding: "utf8", windowsHide: true });
1789
1951
  messages.push(`auto-start registered: HKCU Run '${valueName}' -> hidden wscript launcher "${vbs}"`);
1790
1952
  messages.push(`start it now with: wscript.exe "${vbs}"`);
1791
1953
  }
@@ -1795,7 +1957,7 @@ function runService(options) {
1795
1957
  messages.push(`[dry-run] del ${vbs}`);
1796
1958
  } else {
1797
1959
  try {
1798
- execFileSync("reg", ["delete", runKey, "/v", valueName, "/f"], { encoding: "utf8", windowsHide: true });
1960
+ execFileSync2("reg", ["delete", runKey, "/v", valueName, "/f"], { encoding: "utf8", windowsHide: true });
1799
1961
  } catch {
1800
1962
  }
1801
1963
  rmSync(launcherDir, { recursive: true, force: true });
@@ -1804,9 +1966,9 @@ function runService(options) {
1804
1966
  }
1805
1967
  return { ok: true, messages };
1806
1968
  }
1807
- if (process.platform === "linux") {
1808
- const unitDir = join2(homedir2(), ".config", "systemd", "user");
1809
- const unitFile = join2(unitDir, "agent-comm-hub.service");
1969
+ if (platform === "linux") {
1970
+ const unitDir = join3(homedir3(), ".config", "systemd", "user");
1971
+ const unitFile = join3(unitDir, "agent-comm-hub.service");
1810
1972
  if (options.action === "install") {
1811
1973
  const unit = `[Unit]
1812
1974
  Description=agent-comm-hub (multi-peer MCP hub)
@@ -1841,7 +2003,72 @@ WantedBy=default.target
1841
2003
  }
1842
2004
  return { ok: true, messages };
1843
2005
  }
1844
- return { ok: false, messages: [`auto-start is not implemented for ${process.platform} \u2014 use pm2 or your platform's supervisor`] };
2006
+ if (platform === "darwin") {
2007
+ const launchAgentsDir = join3(homedir3(), "Library", "LaunchAgents");
2008
+ const label = "com.agent-comm-hub";
2009
+ const plist = join3(launchAgentsDir, `${label}.plist`);
2010
+ const logFile = join3(homedir3(), "Library", "Logs", "agent-comm-hub.log");
2011
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
2012
+ const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
2013
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2014
+ <plist version="1.0">
2015
+ <dict>
2016
+ <key>Label</key>
2017
+ <string>${label}</string>
2018
+ <key>ProgramArguments</key>
2019
+ <array>
2020
+ <string>${nodeExe()}</string>
2021
+ <string>${cliPath()}</string>
2022
+ <string>--host</string><string>${host}</string>
2023
+ <string>--port</string><string>${port}</string>
2024
+ <string>--path</string><string>${path2}</string>
2025
+ </array>
2026
+ <key>RunAtLoad</key>
2027
+ <true/>
2028
+ <key>KeepAlive</key>
2029
+ <true/>
2030
+ <key>StandardOutPath</key>
2031
+ <string>${logFile}</string>
2032
+ <key>StandardErrorPath</key>
2033
+ <string>${logFile}</string>
2034
+ </dict>
2035
+ </plist>
2036
+ `;
2037
+ if (options.action === "install") {
2038
+ if (dryRun) {
2039
+ messages.push(`[dry-run] write ${plist}`);
2040
+ messages.push(`[dry-run] launchctl bootstrap gui/${uid} ${plist}`);
2041
+ } else {
2042
+ mkdirSync(launchAgentsDir, { recursive: true });
2043
+ writeFileSync(plist, plistContent);
2044
+ try {
2045
+ execFileSync2("launchctl", ["bootstrap", `gui/${uid}`, plist], { encoding: "utf8", windowsHide: true });
2046
+ } catch {
2047
+ execFileSync2("launchctl", ["load", "-w", plist], { encoding: "utf8", windowsHide: true });
2048
+ }
2049
+ messages.push(`auto-start registered: launchd LaunchAgent ${plist}`);
2050
+ messages.push(`start it now with: launchctl bootstrap gui/${uid} ${plist}`);
2051
+ }
2052
+ } else {
2053
+ if (dryRun) {
2054
+ messages.push(`[dry-run] launchctl bootout gui/${uid}/${label}`);
2055
+ messages.push(`[dry-run] rm ${plist}`);
2056
+ } else {
2057
+ try {
2058
+ execFileSync2("launchctl", ["bootout", `gui/${uid}/${label}`], { encoding: "utf8", windowsHide: true });
2059
+ } catch {
2060
+ try {
2061
+ execFileSync2("launchctl", ["unload", "-w", plist], { encoding: "utf8", windowsHide: true });
2062
+ } catch {
2063
+ }
2064
+ }
2065
+ rmSync(plist, { force: true });
2066
+ messages.push("auto-start removed (launchd LaunchAgent)");
2067
+ }
2068
+ }
2069
+ return { ok: true, messages };
2070
+ }
2071
+ return { ok: false, messages: [`auto-start is not implemented for ${platform} \u2014 use pm2 or your platform's supervisor`] };
1845
2072
  } catch (error) {
1846
2073
  return { ok: false, messages: [`${error.message}`] };
1847
2074
  }
@@ -1851,7 +2078,7 @@ WantedBy=default.target
1851
2078
  function parseArgs(argv) {
1852
2079
  const args = {};
1853
2080
  const numeric = /* @__PURE__ */ new Set(["--port", "--max-queue", "--history-limit", "--wait-timeout-ms", "--default-wait-ms", "--connected-window-ms", "--peer-idle-timeout-ms", "--herdr-timeout-ms"]);
1854
- const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--herdr-bin"]);
2081
+ const string = /* @__PURE__ */ new Set(["--host", "--path", "--url", "--server-name", "--agent", "--herdr-bin"]);
1855
2082
  for (let i = 0; i < argv.length; i++) {
1856
2083
  const flag = argv[i];
1857
2084
  if (flag === "--help" || flag === "-h" || flag === "--version" || flag === "-V") {
@@ -1887,10 +2114,13 @@ Usage:
1887
2114
  every installed agent (incremental,
1888
2115
  idempotent; --remove undoes)
1889
2116
  agent-comm-hub status [options] show hub health + online peers
2117
+ agent-comm-hub discover list installed agents (registry-
2118
+ driven; no config changes)
1890
2119
  agent-comm-hub service install|uninstall [options]
1891
2120
  one-shot auto-start (Windows Run
1892
2121
  key + hidden VBS launcher, no admin;
1893
- Linux systemd; --dry-run prints)
2122
+ Linux systemd, macOS launchd;
2123
+ --dry-run prints)
1894
2124
  agent-comm-hub update self-update from the npm registry
1895
2125
  (files updated in place; restart
1896
2126
  the hub afterwards)
@@ -1912,6 +2142,7 @@ Hub options:
1912
2142
  Setup options:
1913
2143
  --url <url> Hub endpoint to register (default http://127.0.0.1:18764/mcp)
1914
2144
  --server-name <name> Config key (default agent-hub)
2145
+ --agent <id> Only configure one registry agent (e.g. codex)
1915
2146
  --remove Uninstall instead of install
1916
2147
 
1917
2148
  -h, --help Show this help
@@ -1936,11 +2167,16 @@ try {
1936
2167
  await runSetup({
1937
2168
  url: args2["--url"],
1938
2169
  serverName: args2["--server-name"],
2170
+ agent: args2["--agent"],
1939
2171
  remove: args2["--remove"] === true,
1940
2172
  log: (message) => log.info(message)
1941
2173
  });
1942
2174
  process.exit(0);
1943
2175
  }
2176
+ if (command === "discover") {
2177
+ runDiscover({ log: (message) => log.info(message) });
2178
+ process.exit(0);
2179
+ }
1944
2180
  if (command === "status") {
1945
2181
  const args2 = parseArgs(rest);
1946
2182
  const result = await runStatus({
package/lib/index.js CHANGED
@@ -1337,7 +1337,7 @@ function readBody(req) {
1337
1337
 
1338
1338
  // src/index.ts
1339
1339
  var SERVER_NAME = "agent-comm-hub";
1340
- var SERVER_VERSION = "0.3.0";
1340
+ var SERVER_VERSION = "0.4.0";
1341
1341
  var DEFAULT_HOST = "127.0.0.1";
1342
1342
  var DEFAULT_PORT = 18764;
1343
1343
  var DEFAULT_PATH = "/mcp";
package/lib/setup.js CHANGED
@@ -1,13 +1,177 @@
1
1
  // src/setup.ts
2
- import { copyFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
- import { existsSync } from "node:fs";
2
+ import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { existsSync as existsSync2 } from "node:fs";
4
+ import { homedir as homedir2 } from "node:os";
5
+ import { dirname as dirname2, join as join2 } from "node:path";
6
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
7
+
8
+ // src/discover.ts
9
+ import { execFileSync } from "node:child_process";
10
+ import { existsSync, readdirSync, accessSync, readFileSync, constants as fsConstants } from "node:fs";
4
11
  import { homedir } from "node:os";
5
- import { dirname, join } from "node:path";
12
+ import { dirname, join, sep } from "node:path";
6
13
  import { fileURLToPath } from "node:url";
14
+ function registryFile() {
15
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
16
+ }
17
+ function expandHome(file, home) {
18
+ return file.startsWith("~/") ? join(home, file.slice(2)) : file;
19
+ }
20
+ function expandConfigFile(file, home) {
21
+ const expanded = expandHome(file, home);
22
+ const star = expanded.indexOf("*");
23
+ if (star < 0) return [expanded];
24
+ const prefix = expanded.slice(0, star);
25
+ const suffix = expanded.slice(star + 1);
26
+ const base = prefix.slice(0, prefix.lastIndexOf(sep));
27
+ if (!existsSync(base)) return [];
28
+ return readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(base, entry.name, suffix));
29
+ }
30
+ function validateRegistry(registry) {
31
+ if (!Array.isArray(registry.agents)) throw new Error('registry: missing "agents" array');
32
+ const ids = /* @__PURE__ */ new Set();
33
+ for (const agent of registry.agents) {
34
+ if (typeof agent.id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(agent.id)) {
35
+ throw new Error(`registry: bad agent id ${JSON.stringify(agent.id)}`);
36
+ }
37
+ if (ids.has(agent.id)) throw new Error(`registry: duplicate agent id '${agent.id}'`);
38
+ ids.add(agent.id);
39
+ if (!Array.isArray(agent.probe)) throw new Error(`registry: ${agent.id}: probe must be an array`);
40
+ if (agent.npm !== void 0 && !Array.isArray(agent.npm)) throw new Error(`registry: ${agent.id}: npm must be an array`);
41
+ if (!Array.isArray(agent.configs)) throw new Error(`registry: ${agent.id}: configs must be an array`);
42
+ for (const config of agent.configs) {
43
+ if (typeof config.file !== "string" || !config.file.startsWith("~/")) {
44
+ throw new Error(`registry: ${agent.id}: config file must be '~'-relative`);
45
+ }
46
+ if (config.file.split("/").includes("..")) {
47
+ throw new Error(`registry: ${agent.id}: config file must not contain '..'`);
48
+ }
49
+ if (config.file.split("*").length > 2) {
50
+ throw new Error(`registry: ${agent.id}: at most one '*' segment allowed`);
51
+ }
52
+ if (!["json", "toml", "dsh"].includes(config.strategy)) {
53
+ throw new Error(`registry: ${agent.id}: unknown strategy '${config.strategy}'`);
54
+ }
55
+ if (config.strategy === "json" && (typeof config.section !== "string" || config.entry === null)) {
56
+ throw new Error(`registry: ${agent.id}: json strategy needs a section and an entry`);
57
+ }
58
+ if (config.strategy !== "json" && config.entry !== null) {
59
+ throw new Error(`registry: ${agent.id}: only json strategy may carry an entry`);
60
+ }
61
+ }
62
+ if (agent.skill !== null && (typeof agent.skill !== "string" || !agent.skill.startsWith("~/"))) {
63
+ throw new Error(`registry: ${agent.id}: skill must be '~'-relative or null`);
64
+ }
65
+ if (agent.os !== void 0 && (!Array.isArray(agent.os) || agent.os.some((os) => !["win32", "darwin", "linux"].includes(os)))) {
66
+ throw new Error(`registry: ${agent.id}: invalid os list`);
67
+ }
68
+ }
69
+ }
70
+ function loadRegistry(file = registryFile()) {
71
+ const registry = JSON.parse(readFileSync(file, "utf8"));
72
+ validateRegistry(registry);
73
+ return registry;
74
+ }
75
+ function commandOnPath(command, pathEnv, pathext, platform) {
76
+ const dirs = pathEnv.split(platform === "win32" ? ";" : ":");
77
+ const extensions = platform === "win32" ? ["", ...(pathext || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)] : [""];
78
+ for (const dir of dirs) {
79
+ const base = dir === "" ? "." : dir;
80
+ for (const ext of extensions) {
81
+ const candidate = join(base, command + ext);
82
+ try {
83
+ accessSync(candidate, fsConstants.X_OK);
84
+ return true;
85
+ } catch {
86
+ }
87
+ }
88
+ }
89
+ return false;
90
+ }
91
+ function readNpmNames(root) {
92
+ const names = [];
93
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
94
+ if (!entry.isDirectory()) continue;
95
+ if (entry.name.startsWith("@")) {
96
+ const scopeDir = join(root, entry.name);
97
+ for (const sub of readdirSync(scopeDir, { withFileTypes: true })) {
98
+ if (sub.isDirectory()) names.push(sub.name);
99
+ }
100
+ } else {
101
+ names.push(entry.name);
102
+ }
103
+ }
104
+ return names;
105
+ }
106
+ function npmGlobalRoot(platform) {
107
+ try {
108
+ const out = execFileSync(platform === "win32" ? "npm.cmd" : "npm", ["root", "-g"], { encoding: "utf8", windowsHide: true }).trim();
109
+ return out || null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+ function npmFallbackRoots(home, platform) {
115
+ if (platform === "win32") {
116
+ const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
117
+ return [join(appData, "npm", "node_modules")];
118
+ }
119
+ return [
120
+ "/usr/local/lib/node_modules",
121
+ "/usr/lib/node_modules",
122
+ ...existsSync(join(home, ".nvm", "versions", "node")) ? readdirSync(join(home, ".nvm", "versions", "node")).map((dir) => join(home, ".nvm", "versions", "node", dir, "lib", "node_modules")) : []
123
+ ];
124
+ }
125
+ function discover(registry, options = {}) {
126
+ const home = options.homeDir ?? homedir();
127
+ const platform = options.platform ?? process.platform;
128
+ const pathEnv = options.pathEnv ?? process.env.PATH ?? "";
129
+ const pathext = options.pathext ?? process.env.PATHEXT ?? "";
130
+ let npmNames = null;
131
+ if (!options.noNpm) {
132
+ if (options.npmRoot !== void 0 && options.npmRoot !== null) {
133
+ npmNames = existsSync(options.npmRoot) ? readNpmNames(options.npmRoot) : [];
134
+ } else if (options.npmRoot !== null) {
135
+ const root = npmGlobalRoot(platform);
136
+ if (root && existsSync(root)) {
137
+ npmNames = readNpmNames(root);
138
+ } else {
139
+ npmNames = [];
140
+ for (const fallback of npmFallbackRoots(home, platform)) {
141
+ if (existsSync(fallback)) {
142
+ npmNames = readNpmNames(fallback);
143
+ break;
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
149
+ return registry.agents.filter((agent) => agent.os === void 0 || agent.os.includes(platform)).map((agent) => {
150
+ let source = "none";
151
+ const configFiles = [];
152
+ for (const config of agent.configs) {
153
+ for (const file of expandConfigFile(config.file, home)) {
154
+ if (existsSync(file)) {
155
+ configFiles.push(file);
156
+ if (source === "none") source = "config";
157
+ }
158
+ }
159
+ }
160
+ if (source === "none" && agent.probe.some((command) => commandOnPath(command, pathEnv, pathext, platform))) {
161
+ source = "path";
162
+ }
163
+ if (source === "none" && npmNames !== null && (agent.probe.some((command) => npmNames.includes(command)) || (agent.npm ?? []).some((name) => npmNames.includes(name)))) {
164
+ source = "npm";
165
+ }
166
+ return { id: agent.id, source, configFiles, present: source !== "none" };
167
+ });
168
+ }
169
+
170
+ // src/setup.ts
7
171
  var DEFAULT_URL = "http://127.0.0.1:18764/mcp";
8
172
  var DEFAULT_SERVER = "agent-hub";
9
173
  function defaultSkillSrc() {
10
- return join(dirname(fileURLToPath(import.meta.url)), "..", "agents", "SKILL.md");
174
+ return join2(dirname2(fileURLToPath2(import.meta.url)), "..", "agents", "SKILL.md");
11
175
  }
12
176
  function stamp() {
13
177
  const now = /* @__PURE__ */ new Date();
@@ -15,7 +179,7 @@ function stamp() {
15
179
  return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
16
180
  }
17
181
  async function readJson(file) {
18
- if (!existsSync(file)) return null;
182
+ if (!existsSync2(file)) return null;
19
183
  try {
20
184
  return JSON.parse(await readFile(file, "utf8"));
21
185
  } catch (error) {
@@ -23,7 +187,7 @@ async function readJson(file) {
23
187
  }
24
188
  }
25
189
  async function writeJsonNoBom(file, doc) {
26
- await mkdir(dirname(file), { recursive: true });
190
+ await mkdir(dirname2(file), { recursive: true });
27
191
  await writeFile(file, JSON.stringify(doc, null, 2) + "\n", "utf8");
28
192
  }
29
193
  async function backup(file) {
@@ -44,7 +208,7 @@ function resolveSection(doc, section) {
44
208
  return node;
45
209
  }
46
210
  async function mergeJsonServer(file, section, entry, opts) {
47
- if (!existsSync(file)) return "skipped";
211
+ if (!existsSync2(file)) return "skipped";
48
212
  const doc = await readJson(file);
49
213
  if (doc === null) return "skipped";
50
214
  const servers = resolveSection(doc, section);
@@ -66,7 +230,7 @@ async function mergeJsonServer(file, section, entry, opts) {
66
230
  return "changed";
67
231
  }
68
232
  async function mergeTomlSection(file, opts) {
69
- if (!existsSync(file)) return "skipped";
233
+ if (!existsSync2(file)) return "skipped";
70
234
  const text = await readFile(file, "utf8");
71
235
  const marker = `[mcp_servers.${opts.serverName}]`;
72
236
  const markerRe = new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\]`, "m");
@@ -104,7 +268,7 @@ ${DSH_PATCH_MARKER} (installed by \`agent-comm-hub setup\`; undo with \`setup --
104
268
  `;
105
269
  }
106
270
  async function mergeDshPatch(file, opts) {
107
- if (!existsSync(file)) return "skipped";
271
+ if (!existsSync2(file)) return "skipped";
108
272
  const text = await readFile(file, "utf8");
109
273
  const lines = text.split("\n");
110
274
  const markerLine = lines.findIndex((line) => line.includes(DSH_PATCH_MARKER));
@@ -154,29 +318,40 @@ async function mergeDshPatch(file, opts) {
154
318
  }
155
319
  async function syncSkill(skillDir, skillSrc, remove, log) {
156
320
  if (remove) {
157
- if (existsSync(skillDir)) {
158
- await mkdir(dirname(skillDir), { recursive: true });
321
+ if (existsSync2(skillDir)) {
322
+ await mkdir(dirname2(skillDir), { recursive: true });
159
323
  await rmRecursive(skillDir);
160
324
  log(` skill removed: ${skillDir}`);
161
325
  }
162
326
  return;
163
327
  }
164
- if (!existsSync(skillSrc)) {
328
+ if (!existsSync2(skillSrc)) {
165
329
  log(` SKILL.md source missing: ${skillSrc} (skipped)`);
166
330
  return;
167
331
  }
168
332
  await mkdir(skillDir, { recursive: true });
169
- await copyFile(skillSrc, join(skillDir, "SKILL.md"));
170
- log(` skill -> ${join(skillDir, "SKILL.md")}`);
333
+ await copyFile(skillSrc, join2(skillDir, "SKILL.md"));
334
+ log(` skill -> ${join2(skillDir, "SKILL.md")}`);
171
335
  }
172
336
  async function rmRecursive(dir) {
173
337
  const { rm } = await import("node:fs/promises");
174
338
  await rm(dir, { recursive: true, force: true });
175
339
  }
340
+ function substitute(entry, values) {
341
+ const out = {};
342
+ for (const [key, value] of Object.entries(entry)) {
343
+ if (typeof value === "string") {
344
+ out[key] = value.replaceAll("{url}", values.url).replaceAll("{serverName}", values.serverName);
345
+ } else {
346
+ out[key] = value;
347
+ }
348
+ }
349
+ return out;
350
+ }
176
351
  async function runSetup(options = {}) {
177
352
  const url = options.url ?? DEFAULT_URL;
178
353
  const serverName = options.serverName ?? DEFAULT_SERVER;
179
- const home = options.homeDir ?? homedir();
354
+ const home = options.homeDir ?? homedir2();
180
355
  const skillSrc = options.skillSrc ?? defaultSkillSrc();
181
356
  const remove = options.remove === true;
182
357
  const log = options.log ?? ((message) => console.log(message));
@@ -186,65 +361,40 @@ async function runSetup(options = {}) {
186
361
  else if (status === "unchanged" || status === "absent") summary.unchanged.push(`${label}: ${file}`);
187
362
  else if (status === "skipped") summary.skipped.push(`${label}: ${file}`);
188
363
  };
189
- const jsonTargets = [
190
- { label: "mcode", file: join(home, ".minimax", "mcp.json"), section: "mcpServers", entry: { url, type: "streamable-http", enabled: true, configured: true, timeout: 12e4, description: "agent-comm-hub: talk to every other agent connected to the hub." } },
191
- { label: "mcode", file: join(home, ".minimax", "mcp", "mcp.json"), section: "mcpServers", entry: { url, type: "streamable-http", enabled: true, configured: true, timeout: 12e4, description: "agent-comm-hub: talk to every other agent connected to the hub." } },
192
- { label: "opencode", file: join(home, ".config", "opencode", "opencode.json"), section: "mcp", entry: { type: "remote", url, enabled: true } },
193
- { label: "kimi-code", file: join(home, ".kimi-code", "mcp.json"), section: "mcpServers", entry: { transport: "http", url, startupTimeoutMs: 3e4, toolTimeoutMs: 12e4 } },
194
- { label: "gemini-cli", file: join(home, ".gemini", "settings.json"), section: "mcpServers", entry: { type: "http", url } },
195
- { label: "zcode", file: join(home, ".zcode", "cli", "config.json"), section: "mcp.servers", entry: { type: "remote", url, enabled: true } }
196
- ];
197
- for (const target of jsonTargets) {
198
- try {
199
- const status = await mergeJsonServer(target.file, target.section, target.entry, { serverName, url, remove });
200
- record(status, target.label, target.file);
201
- } catch (error) {
202
- summary.errors.push(`${target.label}: ${target.file} \u2014 ${error.message}`);
203
- log(` ${target.label}: SKIPPED \u2014 ${error.message}`);
364
+ const registry = loadRegistry();
365
+ const found = discover(registry, { homeDir: home, pathEnv: options.pathEnv, noNpm: options.noNpm === true });
366
+ const only = options.agent;
367
+ let targetAgents = [];
368
+ if (only !== void 0) {
369
+ const match = registry.agents.find((agent) => agent.id === only);
370
+ if (match === void 0) {
371
+ log(`agent '${only}' is not in the registry (see agents/registry.json)`);
372
+ } else {
373
+ targetAgents = [match];
374
+ log(`configure only: ${only}`);
204
375
  }
376
+ } else {
377
+ targetAgents = found.filter((agent) => agent.present).map((agent) => registry.agents.find((entry) => entry.id === agent.id));
378
+ const present = targetAgents.map((agent) => agent.id);
379
+ log(`discovered: ${present.length > 0 ? present.join(", ") : "none"}`);
205
380
  }
206
- const codexFile = join(home, ".codex", "config.toml");
207
- try {
208
- const status = await mergeTomlSection(codexFile, { serverName, url, remove });
209
- record(status, "codex", codexFile);
210
- } catch (error) {
211
- summary.errors.push(`codex: ${codexFile} \u2014 ${error.message}`);
212
- log(` codex: SKIPPED \u2014 ${error.message}`);
213
- }
214
- const dshProfilesDir = join(home, ".dsh", "profiles");
215
- if (existsSync(dshProfilesDir)) {
216
- let entries = [];
217
- try {
218
- entries = await readdir(dshProfilesDir, { withFileTypes: true });
219
- } catch (error) {
220
- summary.errors.push(`dsh profiles scan \u2014 ${error.message}`);
221
- }
222
- for (const entry of entries) {
223
- if (!entry.isDirectory()) continue;
224
- const patch = join(dshProfilesDir, entry.name, "cordis.patch.yml");
225
- try {
226
- const status = await mergeDshPatch(patch, { serverName, url, remove });
227
- record(status, `dsh (${entry.name})`, patch);
228
- } catch (error) {
229
- summary.errors.push(`dsh (${entry.name}): ${patch} \u2014 ${error.message}`);
230
- log(` dsh (${entry.name}): SKIPPED \u2014 ${error.message}`);
381
+ for (const agent of targetAgents) {
382
+ for (const config of agent.configs) {
383
+ for (const file of expandConfigFile(config.file, home)) {
384
+ try {
385
+ const status = config.strategy === "json" ? await mergeJsonServer(file, config.section, substitute(config.entry, { url, serverName }), { serverName, url, remove }) : config.strategy === "toml" ? await mergeTomlSection(file, { serverName, url, remove }) : await mergeDshPatch(file, { serverName, url, remove });
386
+ record(status, agent.id, file);
387
+ } catch (error) {
388
+ summary.errors.push(`${agent.id}: ${file} \u2014 ${error.message}`);
389
+ log(` ${agent.id}: SKIPPED \u2014 ${error.message}`);
390
+ }
231
391
  }
232
392
  }
233
393
  }
234
- const skillDirs = [
235
- join(home, ".agents", "skills", serverName),
236
- // cross-agent standard
237
- join(home, ".minimax", "skills", serverName),
238
- join(home, ".config", "opencode", "skills", serverName),
239
- join(home, ".kimi-code", "skills", serverName),
240
- join(home, ".gemini", "skills", serverName),
241
- join(home, ".codex", "skills", serverName),
242
- join(home, ".zcode", "skills", serverName),
243
- join(home, ".claude", "skills", serverName),
244
- // config is manual; skill still useful
245
- join(home, ".dsh", "skills", serverName)
246
- // DSH skill (config auto-installed)
247
- ];
394
+ const skillDirs = [join2(home, ".agents", "skills", serverName)];
395
+ for (const agent of targetAgents) {
396
+ if (agent.skill !== null) skillDirs.push(join2(expandHome(agent.skill, home), serverName));
397
+ }
248
398
  for (const dir of skillDirs) {
249
399
  try {
250
400
  await syncSkill(dir, skillSrc, remove, log);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-comm-hub",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Generic multi-peer MCP hub: any MCP-capable agent (MiniMax Code, Claude Code, opencode, Codex, Gemini CLI, DSH, ...) connects to one local streamable-http endpoint and they chat, delegate tasks, and acknowledge in real time",
5
5
  "keywords": [
6
6
  "mcp",
@@ -36,9 +36,9 @@
36
36
  },
37
37
  "scripts": {
38
38
  "build": "esbuild src/cli.ts --bundle --platform=node --format=esm --outfile=lib/cli.js && esbuild src/index.ts --bundle --platform=node --format=esm --outfile=lib/index.js && esbuild src/setup.ts --bundle --platform=node --format=esm --outfile=lib/setup.js",
39
- "build:test": "esbuild test/entry.ts --bundle --platform=node --format=esm --outfile=test/entry.mjs && esbuild test/setup-entry.ts --bundle --platform=node --format=esm --outfile=test/setup-entry.mjs && esbuild test/ops-entry.ts --bundle --platform=node --format=esm --outfile=test/ops-entry.mjs && esbuild test/herdr-entry.ts --bundle --platform=node --format=esm --outfile=test/herdr-entry.mjs",
39
+ "build:test": "esbuild test/entry.ts --bundle --platform=node --format=esm --outfile=test/entry.mjs && esbuild test/setup-entry.ts --bundle --platform=node --format=esm --outfile=test/setup-entry.mjs && esbuild test/ops-entry.ts --bundle --platform=node --format=esm --outfile=test/ops-entry.mjs && esbuild test/herdr-entry.ts --bundle --platform=node --format=esm --outfile=test/herdr-entry.mjs && esbuild test/discover-entry.ts --bundle --platform=node --format=esm --outfile=test/discover-entry.mjs",
40
40
  "typecheck": "tsc --noEmit",
41
- "test": "pnpm run build:test && node test/smoke.mjs && node test/setup.mjs && node test/ops.mjs && node test/herdr.mjs",
41
+ "test": "pnpm run build:test && node test/smoke.mjs && node test/setup.mjs && node test/ops.mjs && node test/herdr.mjs && node test/discover.mjs",
42
42
  "pack": "pnpm run build && pnpm pack",
43
43
  "prepublishOnly": "pnpm run test && pnpm run build",
44
44
  "prepare": "pnpm run build"