agent-comm-hub 0.9.0 → 0.10.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
@@ -11,6 +11,16 @@ import { existsSync, readdirSync, accessSync, readFileSync, constants as fsConst
11
11
  import { homedir } from "node:os";
12
12
  import { dirname, join, sep } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
+ var CREDENTIAL_FILES = [
15
+ "~/.claude.json",
16
+ "~/.dsh/.credentials.yaml",
17
+ "~/.dsh/settings.yaml",
18
+ "~/.codex/auth.json",
19
+ "~/.qwen/mcp-oauth-tokens.json"
20
+ ];
21
+ function configsForPlatform(agent, platform) {
22
+ return agent.configs.filter((config) => config.os === void 0 || config.os.includes(platform));
23
+ }
14
24
  function registryFile() {
15
25
  return join(dirname(fileURLToPath(import.meta.url)), "..", "agents", "registry.json");
16
26
  }
@@ -25,6 +35,9 @@ function expandConfigFile(file, home) {
25
35
  const suffix = expanded.slice(star + 1);
26
36
  const base = prefix.slice(0, prefix.lastIndexOf(sep));
27
37
  if (!existsSync(base)) return [];
38
+ if (suffix.startsWith(sep) === false) {
39
+ return readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isFile() && entry.name.endsWith(suffix)).map((entry) => join(base, entry.name));
40
+ }
28
41
  return readdirSync(base, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(base, entry.name, suffix));
29
42
  }
30
43
  function validateRegistry(registry) {
@@ -38,6 +51,17 @@ function validateRegistry(registry) {
38
51
  ids.add(agent.id);
39
52
  if (!Array.isArray(agent.probe)) throw new Error(`registry: ${agent.id}: probe must be an array`);
40
53
  if (agent.npm !== void 0 && !Array.isArray(agent.npm)) throw new Error(`registry: ${agent.id}: npm must be an array`);
54
+ if (agent.probePaths !== void 0) {
55
+ if (!Array.isArray(agent.probePaths)) throw new Error(`registry: ${agent.id}: probePaths must be an array`);
56
+ for (const probePath of agent.probePaths) {
57
+ if (typeof probePath !== "string" || !probePath.startsWith("~/")) {
58
+ throw new Error(`registry: ${agent.id}: probePath must be '~'-relative`);
59
+ }
60
+ if (probePath.split("/").includes("..")) {
61
+ throw new Error(`registry: ${agent.id}: probePath must not contain '..'`);
62
+ }
63
+ }
64
+ }
41
65
  if (!Array.isArray(agent.configs)) throw new Error(`registry: ${agent.id}: configs must be an array`);
42
66
  for (const config of agent.configs) {
43
67
  if (typeof config.file !== "string" || !config.file.startsWith("~/")) {
@@ -49,14 +73,23 @@ function validateRegistry(registry) {
49
73
  if (config.file.split("*").length > 2) {
50
74
  throw new Error(`registry: ${agent.id}: at most one '*' segment allowed`);
51
75
  }
52
- if (!["json", "toml", "dsh"].includes(config.strategy)) {
76
+ if (!["json", "toml", "dsh", "yaml-array"].includes(config.strategy)) {
53
77
  throw new Error(`registry: ${agent.id}: unknown strategy '${config.strategy}'`);
54
78
  }
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`);
79
+ if (config.os !== void 0 && (!Array.isArray(config.os) || config.os.length === 0 || config.os.some((entry) => !["win32", "darwin", "linux"].includes(entry)))) {
80
+ throw new Error(`registry: ${agent.id}: invalid config os list`);
57
81
  }
58
- if (config.strategy !== "json" && config.entry !== null) {
59
- throw new Error(`registry: ${agent.id}: only json strategy may carry an entry`);
82
+ if (CREDENTIAL_FILES.includes(config.file)) {
83
+ throw new Error(
84
+ `registry: ${agent.id}: ${config.file} holds credentials and must never be written \u2014 point the record at a separate MCP config file, or leave the agent manual (AGENTS.md invariant 6)`
85
+ );
86
+ }
87
+ const keyed = config.strategy === "json" || config.strategy === "yaml-array";
88
+ if (keyed && (typeof config.section !== "string" || config.entry === null)) {
89
+ throw new Error(`registry: ${agent.id}: ${config.strategy} strategy needs a section and an entry`);
90
+ }
91
+ if (!keyed && config.entry !== null) {
92
+ throw new Error(`registry: ${agent.id}: only json/yaml-array strategy may carry an entry`);
60
93
  }
61
94
  }
62
95
  if (agent.skill !== null && (typeof agent.skill !== "string" || !agent.skill.startsWith("~/"))) {
@@ -152,21 +185,25 @@ function discover(registry, options = {}) {
152
185
  return registry.agents.filter((agent) => agent.os === void 0 || agent.os.includes(platform)).map((agent) => {
153
186
  let source = "none";
154
187
  const configFiles = [];
155
- for (const config of agent.configs) {
188
+ for (const config of configsForPlatform(agent, platform)) {
156
189
  for (const file of expandConfigFile(config.file, home)) {
157
- if (existsSync(file)) {
158
- configFiles.push(file);
159
- if (source === "none") source = "config";
160
- }
190
+ if (existsSync(file)) configFiles.push(file);
161
191
  }
162
192
  }
163
- if (source === "none" && agent.probe.some((command) => commandOnPath(command, pathEnv, pathext, platform))) {
193
+ let installed = false;
194
+ if (agent.probe.some((command) => commandOnPath(command, pathEnv, pathext, platform))) {
164
195
  source = "path";
165
- }
166
- if (source === "none" && npmNames !== null && (agent.probe.some((command) => npmNames.includes(command)) || (agent.npm ?? []).some((name) => npmNames.includes(name)))) {
196
+ installed = true;
197
+ } else if (npmNames !== null && (agent.probe.some((command) => npmNames.includes(command)) || (agent.npm ?? []).some((name) => npmNames.includes(name)))) {
167
198
  source = "npm";
199
+ installed = true;
200
+ } else if ((agent.probePaths ?? []).some((probePath) => existsSync(expandHome(probePath, home)))) {
201
+ source = "probe-path";
202
+ installed = true;
168
203
  }
169
- return { id: agent.id, source, configFiles, present: source !== "none" };
204
+ const configOnly = !installed && configFiles.length > 0;
205
+ if (!installed && configOnly) source = "config";
206
+ return { id: agent.id, source, configFiles, present: installed, configOnly };
170
207
  });
171
208
  }
172
209
 
@@ -210,6 +247,19 @@ function resolveSection(doc, section) {
210
247
  }
211
248
  return node;
212
249
  }
250
+ function sameJson(a, b) {
251
+ if (a === b) return true;
252
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
253
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
254
+ if (Array.isArray(a) && Array.isArray(b)) {
255
+ return a.length === b.length && a.every((item, index) => sameJson(item, b[index]));
256
+ }
257
+ const left = a;
258
+ const right = b;
259
+ const leftKeys = Object.keys(left);
260
+ if (leftKeys.length !== Object.keys(right).length) return false;
261
+ return leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && sameJson(left[key], right[key]));
262
+ }
213
263
  async function mergeJsonServer(file, section, entry, opts) {
214
264
  if (!existsSync2(file)) return "skipped";
215
265
  const doc = await readJson(file);
@@ -223,10 +273,7 @@ async function mergeJsonServer(file, section, entry, opts) {
223
273
  await writeJsonNoBom(file, doc);
224
274
  return "removed";
225
275
  }
226
- if (has) {
227
- const existing = servers[opts.serverName];
228
- if (existing?.url === opts.url) return "unchanged";
229
- }
276
+ if (has && sameJson(servers[opts.serverName], entry)) return "unchanged";
230
277
  servers[opts.serverName] = entry;
231
278
  await backup(file);
232
279
  await writeJsonNoBom(file, doc);
@@ -237,19 +284,25 @@ async function mergeTomlSection(file, opts) {
237
284
  const text = await readFile(file, "utf8");
238
285
  const marker = `[mcp_servers.${opts.serverName}]`;
239
286
  const markerRe = new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\]`, "m");
287
+ const sectionRe = new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\][^\\r\\n]*(\\r?\\n(?!\\[).*)*(\\r?\\n)?`, "m");
240
288
  if (opts.remove) {
241
289
  if (!markerRe.test(text)) return "absent";
242
- const cleaned = text.replace(new RegExp(`^\\[mcp_servers\\.${escapeRegExp(opts.serverName)}\\][^\\r\\n]*(\\r?\\n(?!\\[).*)*(\\r?\\n)?`, "m"), "");
290
+ const cleaned = text.replace(sectionRe, "");
243
291
  await backup(file);
244
292
  await writeFile(file, cleaned, "utf8");
245
293
  return "removed";
246
294
  }
247
- if (markerRe.test(text)) return "unchanged";
248
295
  const block = `
249
296
  ${marker}
250
297
  type = "streamable-http"
251
298
  url = "${opts.url}"
252
299
  `;
300
+ if (markerRe.test(text)) {
301
+ if (text.includes(block.trimStart())) return "unchanged";
302
+ await backup(file);
303
+ await writeFile(file, text.replace(sectionRe, "").trimEnd() + block, "utf8");
304
+ return "changed";
305
+ }
253
306
  await backup(file);
254
307
  await writeFile(file, text.trimEnd() + block, "utf8");
255
308
  return "changed";
@@ -312,6 +365,82 @@ async function mergeDshPatch(file, opts) {
312
365
  await writeFile(file, withoutHubEntries().trimEnd() + dshPatchBlock(opts.url, opts.serverName), "utf8");
313
366
  return "changed";
314
367
  }
368
+ async function mergeYamlArrayServer(file, opts) {
369
+ if (!existsSync2(file)) return "skipped";
370
+ const text = await readFile(file, "utf8");
371
+ const lines = text.split("\n");
372
+ const keyIndex = lines.findIndex((line) => /^mcpServers:\s*(#.*)?$/.test(line));
373
+ const inlineKey = lines.findIndex((line) => /^mcpServers:\s*\S/.test(line));
374
+ if (keyIndex === -1) {
375
+ if (inlineKey !== -1) return "unrecognized";
376
+ }
377
+ const blockEnd = (from) => {
378
+ for (let i = from + 1; i < lines.length; i++) {
379
+ const line = lines[i];
380
+ if (line.trim() === "" || line.startsWith("#")) continue;
381
+ if (!/^\s/.test(line)) return i;
382
+ }
383
+ return lines.length;
384
+ };
385
+ const items = (from, to2) => {
386
+ const starts = [];
387
+ for (let i = from; i < to2; i++) {
388
+ if (/^\s*-\s/.test(lines[i])) starts.push(i);
389
+ }
390
+ return starts.map((start, k) => ({ start, end: k + 1 < starts.length ? starts[k + 1] : to2 }));
391
+ };
392
+ const isOurs = (item) => lines.slice(item.start, item.end).some((line) => new RegExp(`^\\s*-?\\s*name:\\s*${opts.serverName}\\s*$`).test(line));
393
+ if (keyIndex === -1) {
394
+ if (opts.remove) return "absent";
395
+ await backup(file);
396
+ const block = `mcpServers:
397
+ ${yamlArrayItem(opts.serverName, opts.url, opts.entry, " ")}`;
398
+ await writeFile(file, `${text.replace(/\n*$/, "")}
399
+ ${block}
400
+ `, "utf8");
401
+ return "changed";
402
+ }
403
+ const to = blockEnd(keyIndex);
404
+ const ours = items(keyIndex + 1, to).filter(isOurs);
405
+ const all = items(keyIndex + 1, to);
406
+ const bodyLines = lines.slice(keyIndex + 1, to).filter((line) => line.trim() !== "" && !line.trim().startsWith("#"));
407
+ if (all.length === 0 && bodyLines.length > 0) return "unrecognized";
408
+ const indent = ours.length > 0 ? lines[ours[0].start].match(/^(\s*)/)?.[1] ?? " " : all.length > 0 ? lines[all[0].start].match(/^(\s*)/)?.[1] ?? " " : " ";
409
+ if (opts.remove) {
410
+ if (ours.length === 0) return "absent";
411
+ await backup(file);
412
+ const out2 = lines.slice();
413
+ for (const item of ours.reverse()) out2.splice(item.start, item.end - item.start);
414
+ await writeFile(file, out2.join("\n"), "utf8");
415
+ return "removed";
416
+ }
417
+ const wanted = yamlArrayItem(opts.serverName, opts.url, opts.entry, indent);
418
+ if (ours.length === 1 && lines.slice(ours[0].start, ours[0].end).join("\n").trimEnd() === wanted.trimEnd()) {
419
+ return "unchanged";
420
+ }
421
+ await backup(file);
422
+ const out = lines.slice();
423
+ const first = ours[0]?.start ?? to;
424
+ for (const item of ours.reverse()) out.splice(item.start, item.end - item.start);
425
+ const insertAt = ours.length > 0 ? first : to;
426
+ out.splice(insertAt, 0, ...wanted.replace(/\n$/, "").split("\n"));
427
+ await writeFile(file, out.join("\n"), "utf8");
428
+ return "changed";
429
+ }
430
+ function yamlArrayItem(serverName, url, entry, indent) {
431
+ const substituted = substitute(entry, { url, serverName });
432
+ const keys = Object.entries(substituted);
433
+ const inner = `${indent} `;
434
+ const head = `${indent}- name: ${serverName}
435
+ `;
436
+ return head + keys.map(([key, value]) => `${inner}${key}: ${yamlScalar(value)}
437
+ `).join("");
438
+ }
439
+ function yamlScalar(value) {
440
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
441
+ const text = String(value ?? "");
442
+ return /^[A-Za-z0-9._/:@-]+$/.test(text) ? text : JSON.stringify(text);
443
+ }
315
444
  async function syncSkill(skillDir, skillSrc, remove, log) {
316
445
  if (remove) {
317
446
  if (existsSync2(skillDir)) {
@@ -351,13 +480,14 @@ async function runSetup(options = {}) {
351
480
  const skillSrc = options.skillSrc ?? defaultSkillSrc();
352
481
  const remove = options.remove === true;
353
482
  const log = options.log ?? ((message) => console.log(message));
354
- const summary = { done: [], unchanged: [], skipped: [], errors: [] };
483
+ const summary = { done: [], unchanged: [], skipped: [], refused: [], errors: [] };
355
484
  const record = (status, label, file) => {
356
485
  if (status === "changed" || status === "removed") summary.done.push(`${label}: ${file}`);
357
486
  else if (status === "unchanged" || status === "absent") summary.unchanged.push(`${label}: ${file}`);
358
487
  else if (status === "skipped") summary.skipped.push(`${label}: ${file}`);
488
+ else if (status === "unrecognized") summary.refused.push(`${label}: ${file}`);
359
489
  };
360
- const registry = loadRegistry();
490
+ const registry = loadRegistry(options.registryFile);
361
491
  const found = discover(registry, { homeDir: home, pathEnv: options.pathEnv, noNpm: options.noNpm === true });
362
492
  const only = options.agent;
363
493
  let targetAgents = [];
@@ -370,16 +500,43 @@ async function runSetup(options = {}) {
370
500
  log(`configure only: ${only}`);
371
501
  }
372
502
  } else {
373
- targetAgents = found.filter((agent) => agent.present).map((agent) => registry.agents.find((entry) => entry.id === agent.id));
503
+ const installed = found.filter((agent) => agent.present);
504
+ targetAgents = installed.map((agent) => registry.agents.find((entry) => entry.id === agent.id));
505
+ const configOnly = found.filter((agent) => agent.configOnly);
506
+ if (options.force === true) {
507
+ for (const agent of configOnly) {
508
+ const entry = registry.agents.find((candidate) => candidate.id === agent.id);
509
+ if (entry !== void 0 && !targetAgents.includes(entry)) targetAgents.push(entry);
510
+ }
511
+ } else if (configOnly.length > 0) {
512
+ for (const agent of configOnly) {
513
+ summary.skipped.push(`${agent.id}: config-only (${agent.configFiles[0]}) \u2014 agent not installed; use --force or --agent ${agent.id}`);
514
+ }
515
+ log(`skipped (config-only, agent not installed): ${configOnly.map((agent) => agent.id).join(", ")} \u2014 use --force to configure anyway`);
516
+ }
374
517
  const present = targetAgents.map((agent) => agent.id);
375
518
  log(`discovered: ${present.length > 0 ? present.join(", ") : "none"}`);
376
519
  }
377
520
  for (const agent of targetAgents) {
378
- for (const config of agent.configs) {
521
+ for (const config of configsForPlatform(agent, process.platform)) {
379
522
  const nameFor = config.serverName ?? agent.serverName ?? serverName;
380
523
  for (const file of expandConfigFile(config.file, home)) {
381
524
  try {
382
- const status = config.strategy === "json" ? await mergeJsonServer(file, config.section, substitute(config.entry, { url, serverName: nameFor }), { serverName: nameFor, url, remove }) : config.strategy === "toml" ? await mergeTomlSection(file, { serverName: nameFor, url, remove }) : await mergeDshPatch(file, { serverName: nameFor, url, remove });
525
+ let status;
526
+ switch (config.strategy) {
527
+ case "json":
528
+ status = await mergeJsonServer(file, config.section, substitute(config.entry, { url, serverName: nameFor }), { serverName: nameFor, url, remove });
529
+ break;
530
+ case "toml":
531
+ status = await mergeTomlSection(file, { serverName: nameFor, url, remove });
532
+ break;
533
+ case "yaml-array":
534
+ status = await mergeYamlArrayServer(file, { serverName: nameFor, url, remove, entry: config.entry ?? {} });
535
+ break;
536
+ case "dsh":
537
+ status = await mergeDshPatch(file, { serverName: nameFor, url, remove });
538
+ break;
539
+ }
383
540
  record(status, agent.id, file);
384
541
  } catch (error) {
385
542
  summary.errors.push(`${agent.id}: ${file} \u2014 ${error.message}`);
@@ -388,7 +545,8 @@ async function runSetup(options = {}) {
388
545
  }
389
546
  }
390
547
  }
391
- const skillDirs = [join2(home, ".agents", "skills", serverName)];
548
+ const skillDirs = [];
549
+ if (targetAgents.length > 0 || remove) skillDirs.push(join2(home, ".agents", "skills", serverName));
392
550
  for (const agent of targetAgents) {
393
551
  if (agent.skill !== null) skillDirs.push(join2(expandHome(agent.skill, home), serverName));
394
552
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-comm-hub",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",