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/README.md +71 -15
- package/README.zh.md +2 -2
- package/agents/SKILL.md +74 -12
- package/agents/install-all.ps1 +25 -160
- package/agents/registry.json +175 -7
- package/lib/cli.js +1954 -222
- package/lib/index.js +1682 -158
- package/lib/setup.js +184 -26
- package/package.json +1 -1
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.
|
|
56
|
-
throw new Error(`registry: ${agent.id}:
|
|
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 (
|
|
59
|
-
throw new Error(
|
|
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
|
|
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
|
-
|
|
193
|
+
let installed = false;
|
|
194
|
+
if (agent.probe.some((command) => commandOnPath(command, pathEnv, pathext, platform))) {
|
|
164
195
|
source = "path";
|
|
165
|
-
|
|
166
|
-
if (
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 = [
|
|
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.
|
|
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",
|