agent-comm-hub 0.2.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/lib/setup.js CHANGED
@@ -1,13 +1,177 @@
1
1
  // src/setup.ts
2
2
  import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
3
- import { existsSync } from "node:fs";
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");
@@ -90,31 +254,104 @@ url = "${opts.url}"
90
254
  function escapeRegExp(value) {
91
255
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
92
256
  }
257
+ var DSH_PATCH_MARKER = "# \u2500\u2500 agent-comm-hub MCP client";
258
+ function dshPatchBlock(url, serverName) {
259
+ return `
260
+ ${DSH_PATCH_MARKER} (installed by \`agent-comm-hub setup\`; undo with \`setup --remove\`) \u2500
261
+ - insert:
262
+ - id: ${serverName}
263
+ name: '@deepseek-ai/dsh-mcp-client'
264
+ config:
265
+ serverName: ${serverName}
266
+ transport: streamable-http
267
+ url: ${url}
268
+ `;
269
+ }
270
+ async function mergeDshPatch(file, opts) {
271
+ if (!existsSync2(file)) return "skipped";
272
+ const text = await readFile(file, "utf8");
273
+ const lines = text.split("\n");
274
+ const markerLine = lines.findIndex((line) => line.includes(DSH_PATCH_MARKER));
275
+ const hasBlock = markerLine >= 0;
276
+ const blockRange = () => {
277
+ let entryStart = -1;
278
+ for (let i = markerLine + 1; i < lines.length; i++) {
279
+ if (/^- /.test(lines[i])) {
280
+ entryStart = i;
281
+ break;
282
+ }
283
+ }
284
+ let end = lines.length;
285
+ if (entryStart >= 0) {
286
+ for (let i = entryStart + 1; i < lines.length; i++) {
287
+ if (/^- /.test(lines[i])) {
288
+ end = i;
289
+ break;
290
+ }
291
+ }
292
+ }
293
+ let start = markerLine;
294
+ while (start > 0 && lines[start - 1].trim() === "") start--;
295
+ return { start, end };
296
+ };
297
+ const withoutBlock = () => {
298
+ const { start, end } = blockRange();
299
+ return lines.slice(0, start).concat(lines.slice(end)).join("\n");
300
+ };
301
+ if (opts.remove) {
302
+ if (!hasBlock) return "absent";
303
+ const cleaned = withoutBlock();
304
+ await backup(file);
305
+ await writeFile(file, cleaned, "utf8");
306
+ return "removed";
307
+ }
308
+ if (hasBlock) {
309
+ if (lines.some((line) => line.includes(`url: ${opts.url}`))) return "unchanged";
310
+ const replaced = withoutBlock();
311
+ await backup(file);
312
+ await writeFile(file, replaced.trimEnd() + dshPatchBlock(opts.url, opts.serverName), "utf8");
313
+ return "changed";
314
+ }
315
+ await backup(file);
316
+ await writeFile(file, text.trimEnd() + dshPatchBlock(opts.url, opts.serverName), "utf8");
317
+ return "changed";
318
+ }
93
319
  async function syncSkill(skillDir, skillSrc, remove, log) {
94
320
  if (remove) {
95
- if (existsSync(skillDir)) {
96
- await mkdir(dirname(skillDir), { recursive: true });
321
+ if (existsSync2(skillDir)) {
322
+ await mkdir(dirname2(skillDir), { recursive: true });
97
323
  await rmRecursive(skillDir);
98
324
  log(` skill removed: ${skillDir}`);
99
325
  }
100
326
  return;
101
327
  }
102
- if (!existsSync(skillSrc)) {
328
+ if (!existsSync2(skillSrc)) {
103
329
  log(` SKILL.md source missing: ${skillSrc} (skipped)`);
104
330
  return;
105
331
  }
106
332
  await mkdir(skillDir, { recursive: true });
107
- await copyFile(skillSrc, join(skillDir, "SKILL.md"));
108
- log(` skill -> ${join(skillDir, "SKILL.md")}`);
333
+ await copyFile(skillSrc, join2(skillDir, "SKILL.md"));
334
+ log(` skill -> ${join2(skillDir, "SKILL.md")}`);
109
335
  }
110
336
  async function rmRecursive(dir) {
111
337
  const { rm } = await import("node:fs/promises");
112
338
  await rm(dir, { recursive: true, force: true });
113
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
+ }
114
351
  async function runSetup(options = {}) {
115
352
  const url = options.url ?? DEFAULT_URL;
116
353
  const serverName = options.serverName ?? DEFAULT_SERVER;
117
- const home = options.homeDir ?? homedir();
354
+ const home = options.homeDir ?? homedir2();
118
355
  const skillSrc = options.skillSrc ?? defaultSkillSrc();
119
356
  const remove = options.remove === true;
120
357
  const log = options.log ?? ((message) => console.log(message));
@@ -124,43 +361,40 @@ async function runSetup(options = {}) {
124
361
  else if (status === "unchanged" || status === "absent") summary.unchanged.push(`${label}: ${file}`);
125
362
  else if (status === "skipped") summary.skipped.push(`${label}: ${file}`);
126
363
  };
127
- const jsonTargets = [
128
- { 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." } },
129
- { 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." } },
130
- { label: "opencode", file: join(home, ".config", "opencode", "opencode.json"), section: "mcp", entry: { type: "remote", url, enabled: true } },
131
- { label: "kimi-code", file: join(home, ".kimi-code", "mcp.json"), section: "mcpServers", entry: { transport: "http", url, startupTimeoutMs: 3e4, toolTimeoutMs: 12e4 } },
132
- { label: "gemini-cli", file: join(home, ".gemini", "settings.json"), section: "mcpServers", entry: { type: "http", url } },
133
- { label: "zcode", file: join(home, ".zcode", "cli", "config.json"), section: "mcp.servers", entry: { type: "remote", url, enabled: true } }
134
- ];
135
- for (const target of jsonTargets) {
136
- try {
137
- const status = await mergeJsonServer(target.file, target.section, target.entry, { serverName, url, remove });
138
- record(status, target.label, target.file);
139
- } catch (error) {
140
- summary.errors.push(`${target.label}: ${target.file} \u2014 ${error.message}`);
141
- 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}`);
142
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"}`);
380
+ }
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
+ }
391
+ }
392
+ }
393
+ }
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));
143
397
  }
144
- const codexFile = join(home, ".codex", "config.toml");
145
- try {
146
- const status = await mergeTomlSection(codexFile, { serverName, url, remove });
147
- record(status, "codex", codexFile);
148
- } catch (error) {
149
- summary.errors.push(`codex: ${codexFile} \u2014 ${error.message}`);
150
- log(` codex: SKIPPED \u2014 ${error.message}`);
151
- }
152
- const skillDirs = [
153
- join(home, ".agents", "skills", serverName),
154
- // cross-agent standard
155
- join(home, ".minimax", "skills", serverName),
156
- join(home, ".config", "opencode", "skills", serverName),
157
- join(home, ".kimi-code", "skills", serverName),
158
- join(home, ".gemini", "skills", serverName),
159
- join(home, ".codex", "skills", serverName),
160
- join(home, ".zcode", "skills", serverName),
161
- join(home, ".claude", "skills", serverName)
162
- // config is manual; skill still useful
163
- ];
164
398
  for (const dir of skillDirs) {
165
399
  try {
166
400
  await syncSkill(dir, skillSrc, remove, log);
@@ -169,8 +403,8 @@ async function runSetup(options = {}) {
169
403
  log(` skill ${dir}: SKIPPED \u2014 ${error.message}`);
170
404
  }
171
405
  }
172
- if (remove) log("done. Manual targets (see agents/README.md): Claude Code (.mcp.json), DSH (cordis.patch.yml).");
173
- else log("done. Manual targets (see agents/README.md): Claude Code (.mcp.json), DSH (cordis.patch.yml). Restart agent sessions to pick up the MCP server.");
406
+ if (remove) log("done. Manual target (see agents/README.md): Claude Code (.mcp.json).");
407
+ else log("done. Manual target (see agents/README.md): Claude Code (.mcp.json). Restart agent sessions (and the dsh profile) to pick up the MCP server.");
174
408
  return summary;
175
409
  }
176
410
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-comm-hub",
3
- "version": "0.2.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",
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",
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"