@triflux/core 10.0.0-alpha.1

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.
Files changed (75) hide show
  1. package/hooks/agent-route-guard.mjs +109 -0
  2. package/hooks/cross-review-tracker.mjs +122 -0
  3. package/hooks/error-context.mjs +148 -0
  4. package/hooks/hook-manager.mjs +352 -0
  5. package/hooks/hook-orchestrator.mjs +312 -0
  6. package/hooks/hook-registry.json +213 -0
  7. package/hooks/hooks.json +89 -0
  8. package/hooks/keyword-rules.json +581 -0
  9. package/hooks/lib/resolve-root.mjs +59 -0
  10. package/hooks/mcp-config-watcher.mjs +85 -0
  11. package/hooks/pipeline-stop.mjs +76 -0
  12. package/hooks/safety-guard.mjs +106 -0
  13. package/hooks/subagent-verifier.mjs +80 -0
  14. package/hub/assign-callbacks.mjs +133 -0
  15. package/hub/bridge.mjs +799 -0
  16. package/hub/cli-adapter-base.mjs +192 -0
  17. package/hub/codex-adapter.mjs +190 -0
  18. package/hub/codex-compat.mjs +78 -0
  19. package/hub/codex-preflight.mjs +147 -0
  20. package/hub/delegator/contracts.mjs +37 -0
  21. package/hub/delegator/index.mjs +14 -0
  22. package/hub/delegator/schema/delegator-tools.schema.json +250 -0
  23. package/hub/delegator/service.mjs +307 -0
  24. package/hub/delegator/tool-definitions.mjs +35 -0
  25. package/hub/fullcycle.mjs +96 -0
  26. package/hub/gemini-adapter.mjs +179 -0
  27. package/hub/hitl.mjs +143 -0
  28. package/hub/intent.mjs +193 -0
  29. package/hub/lib/process-utils.mjs +361 -0
  30. package/hub/middleware/request-logger.mjs +81 -0
  31. package/hub/paths.mjs +30 -0
  32. package/hub/pipeline/gates/confidence.mjs +56 -0
  33. package/hub/pipeline/gates/consensus.mjs +94 -0
  34. package/hub/pipeline/gates/index.mjs +5 -0
  35. package/hub/pipeline/gates/selfcheck.mjs +82 -0
  36. package/hub/pipeline/index.mjs +318 -0
  37. package/hub/pipeline/state.mjs +191 -0
  38. package/hub/pipeline/transitions.mjs +124 -0
  39. package/hub/platform.mjs +225 -0
  40. package/hub/quality/deslop.mjs +253 -0
  41. package/hub/reflexion.mjs +372 -0
  42. package/hub/research.mjs +146 -0
  43. package/hub/router.mjs +791 -0
  44. package/hub/routing/complexity.mjs +166 -0
  45. package/hub/routing/index.mjs +117 -0
  46. package/hub/routing/q-learning.mjs +336 -0
  47. package/hub/session-fingerprint.mjs +352 -0
  48. package/hub/state.mjs +245 -0
  49. package/hub/team-bridge.mjs +25 -0
  50. package/hub/token-mode.mjs +224 -0
  51. package/hub/workers/worker-utils.mjs +104 -0
  52. package/hud/colors.mjs +88 -0
  53. package/hud/constants.mjs +81 -0
  54. package/hud/hud-qos-status.mjs +206 -0
  55. package/hud/providers/claude.mjs +309 -0
  56. package/hud/providers/codex.mjs +151 -0
  57. package/hud/providers/gemini.mjs +320 -0
  58. package/hud/renderers.mjs +424 -0
  59. package/hud/terminal.mjs +140 -0
  60. package/hud/utils.mjs +287 -0
  61. package/package.json +31 -0
  62. package/scripts/lib/claudemd-manager.mjs +325 -0
  63. package/scripts/lib/context.mjs +67 -0
  64. package/scripts/lib/cross-review-utils.mjs +51 -0
  65. package/scripts/lib/env-probe.mjs +241 -0
  66. package/scripts/lib/gemini-profiles.mjs +85 -0
  67. package/scripts/lib/hook-utils.mjs +14 -0
  68. package/scripts/lib/keyword-rules.mjs +166 -0
  69. package/scripts/lib/logger.mjs +105 -0
  70. package/scripts/lib/mcp-filter.mjs +739 -0
  71. package/scripts/lib/mcp-guard-engine.mjs +940 -0
  72. package/scripts/lib/mcp-manifest.mjs +79 -0
  73. package/scripts/lib/mcp-server-catalog.mjs +118 -0
  74. package/scripts/lib/psmux-info.mjs +119 -0
  75. package/scripts/lib/remote-spawn-transfer.mjs +196 -0
@@ -0,0 +1,940 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const PROJECT_ROOT = fileURLToPath(new URL("../../", import.meta.url));
7
+ const REGISTRY_PATH = join(PROJECT_ROOT, "config", "mcp-registry.json");
8
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
9
+ const DEFAULT_HUB_PATH = "/mcp";
10
+ const DEFAULT_REGISTRY = Object.freeze({
11
+ $schema: "mcp-registry-schema",
12
+ version: 1,
13
+ description: "MCP 서버 중앙 레지스트리 — 진실의 원천",
14
+ defaults: {
15
+ transport: "hub-url",
16
+ hub_base: "http://127.0.0.1:27888",
17
+ },
18
+ servers: {
19
+ "tfx-hub": {
20
+ transport: "hub-url",
21
+ url: "http://127.0.0.1:27888/mcp",
22
+ safe: true,
23
+ targets: ["claude", "gemini", "codex"],
24
+ description: "triflux Hub MCP 서버",
25
+ },
26
+ },
27
+ policies: {
28
+ stdio_action: "replace-with-hub",
29
+ unknown_server_action: "warn",
30
+ watched_paths: [
31
+ "~/.gemini/settings.json",
32
+ "~/.codex/config.toml",
33
+ "~/.claude/settings.json",
34
+ "~/.claude/settings.local.json",
35
+ ".mcp.json",
36
+ ],
37
+ },
38
+ });
39
+
40
+ function cloneDefaultRegistry() {
41
+ return JSON.parse(JSON.stringify(DEFAULT_REGISTRY));
42
+ }
43
+
44
+ function expandHome(filePath) {
45
+ if (typeof filePath !== "string") return "";
46
+ if (!filePath.startsWith("~/") && !filePath.startsWith("~\\")) return filePath;
47
+ return join(homedir(), filePath.slice(2));
48
+ }
49
+
50
+ function resolveFilePath(filePath) {
51
+ const expanded = expandHome(filePath);
52
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(process.cwd(), expanded);
53
+ }
54
+
55
+ function normalizeForMatch(filePath) {
56
+ return resolveFilePath(filePath).replace(/\\/g, "/").toLowerCase();
57
+ }
58
+
59
+ function pathBasename(filePath) {
60
+ return basename(filePath.replace(/\\/g, "/")).toLowerCase();
61
+ }
62
+
63
+ function readJsonFile(filePath) {
64
+ return JSON.parse(readFileSync(filePath, "utf8"));
65
+ }
66
+
67
+ function writeJsonFile(filePath, data) {
68
+ mkdirSync(dirname(filePath), { recursive: true });
69
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
70
+ }
71
+
72
+ function ensureBackup(filePath) {
73
+ const backupPath = `${filePath}.bak`;
74
+ copyFileSync(filePath, backupPath);
75
+ return backupPath;
76
+ }
77
+
78
+ function isJsonMcpConfig(filePath) {
79
+ const name = pathBasename(filePath);
80
+ return name === "settings.json" || name === "settings.local.json" || name === ".mcp.json";
81
+ }
82
+
83
+ function isCodexConfig(filePath) {
84
+ const normalized = normalizeForMatch(filePath);
85
+ return normalized.endsWith("/.codex/config.toml");
86
+ }
87
+
88
+ function detectClient(filePath) {
89
+ const normalized = normalizeForMatch(filePath);
90
+ if (normalized.endsWith("/.gemini/settings.json")) return "gemini";
91
+ if (normalized.endsWith("/.codex/config.toml")) return "codex";
92
+ if (
93
+ normalized.endsWith("/.claude/settings.json")
94
+ || normalized.endsWith("/.claude/settings.local.json")
95
+ || normalized.endsWith("/.mcp.json")
96
+ ) {
97
+ return "claude";
98
+ }
99
+ return "unknown";
100
+ }
101
+
102
+ function detectLabel(filePath) {
103
+ const normalized = normalizeForMatch(filePath);
104
+ if (normalized.endsWith("/.gemini/settings.json")) return "Gemini";
105
+ if (normalized.endsWith("/.codex/config.toml")) return "Codex";
106
+ if (normalized.endsWith("/.claude/settings.json")) return "Claude User";
107
+ if (normalized.endsWith("/.claude/settings.local.json")) return "Claude Local";
108
+ if (normalized.endsWith("/.mcp.json")) return "Project MCP";
109
+ return basename(filePath);
110
+ }
111
+
112
+ function isPrimaryConfigTarget(filePath) {
113
+ const normalized = normalizeForMatch(filePath);
114
+ return normalized.endsWith("/.gemini/settings.json")
115
+ || normalized.endsWith("/.codex/config.toml")
116
+ || normalized.endsWith("/.mcp.json");
117
+ }
118
+
119
+ function normalizeUrl(value) {
120
+ if (typeof value !== "string" || !value.trim()) return "";
121
+ try {
122
+ const url = new URL(value.trim());
123
+ if (url.pathname !== "/" && url.pathname.endsWith("/")) {
124
+ url.pathname = url.pathname.replace(/\/+$/, "");
125
+ }
126
+ return url.toString();
127
+ } catch {
128
+ return value.trim();
129
+ }
130
+ }
131
+
132
+ function formatTomlString(value) {
133
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
134
+ }
135
+
136
+ function parseTomlScalar(rawValue) {
137
+ const value = String(rawValue || "").trim();
138
+ if (!value) return "";
139
+ if (value === "true") return true;
140
+ if (value === "false") return false;
141
+ if (/^-?\d[\d_]*$/.test(value)) return Number(value.replace(/_/g, ""));
142
+ if (value.startsWith('"') && value.endsWith('"')) {
143
+ return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
144
+ }
145
+ return value;
146
+ }
147
+
148
+ function parseCodexMcpServers(raw) {
149
+ const lines = String(raw || "").split(/\r?\n/);
150
+ const servers = {};
151
+ let currentName = null;
152
+
153
+ for (const line of lines) {
154
+ const sectionMatch = line.match(/^\s*\[mcp_servers\.([^\]]+)\]\s*$/);
155
+ if (sectionMatch) {
156
+ currentName = sectionMatch[1];
157
+ servers[currentName] = {};
158
+ continue;
159
+ }
160
+
161
+ if (/^\s*\[/.test(line)) {
162
+ currentName = null;
163
+ continue;
164
+ }
165
+
166
+ if (!currentName) continue;
167
+
168
+ const kvMatch = line.match(/^\s*([A-Za-z0-9_]+)\s*=\s*(.+?)\s*$/);
169
+ if (!kvMatch) continue;
170
+ servers[currentName][kvMatch[1]] = parseTomlScalar(kvMatch[2]);
171
+ }
172
+
173
+ return servers;
174
+ }
175
+
176
+ function removeTomlSection(raw, sectionName) {
177
+ const lines = String(raw || "").split(/\r?\n/);
178
+ const output = [];
179
+ const header = `[mcp_servers.${sectionName}]`;
180
+ let skipping = false;
181
+
182
+ for (const line of lines) {
183
+ if (line.trim() === header) {
184
+ skipping = true;
185
+ continue;
186
+ }
187
+
188
+ if (skipping && /^\s*\[/.test(line)) {
189
+ skipping = false;
190
+ }
191
+
192
+ if (!skipping) output.push(line);
193
+ }
194
+
195
+ const cleaned = output.join("\n").replace(/\n{3,}$/g, "\n\n").replace(/^\n+/, "");
196
+ return cleaned.length > 0 ? cleaned.replace(/\n{3,}/g, "\n\n") : "";
197
+ }
198
+
199
+ function upsertTomlUrlServer(raw, name, url) {
200
+ const section = [
201
+ `[mcp_servers.${name}]`,
202
+ `url = ${formatTomlString(url)}`,
203
+ ];
204
+ const withoutExisting = removeTomlSection(raw, name).trimEnd();
205
+ return withoutExisting.length > 0
206
+ ? `${withoutExisting}\n\n${section.join("\n")}\n`
207
+ : `${section.join("\n")}\n`;
208
+ }
209
+
210
+ function getHubServerEntry(registry) {
211
+ const entries = Object.entries(registry?.servers || {});
212
+ if (entries.length === 0) {
213
+ return ["tfx-hub", { url: `${registry?.defaults?.hub_base || "http://127.0.0.1:27888"}${DEFAULT_HUB_PATH}` }];
214
+ }
215
+
216
+ return entries.find(([name]) => name === "tfx-hub")
217
+ || entries.find(([, config]) => config?.transport === "hub-url")
218
+ || entries[0];
219
+ }
220
+
221
+ function makeHubRuntimeConfig() {
222
+ return { url: resolveHubUrl() };
223
+ }
224
+
225
+ function serverTargets(serverConfig) {
226
+ if (Array.isArray(serverConfig?.targets) && serverConfig.targets.length > 0) {
227
+ return [...new Set(serverConfig.targets.map((value) => String(value).trim()).filter(Boolean))];
228
+ }
229
+ return ["claude", "gemini", "codex"];
230
+ }
231
+
232
+ function serverAppliesToClient(serverConfig, client) {
233
+ return serverTargets(serverConfig).includes(client);
234
+ }
235
+
236
+ function buildDesiredServerRecord(name, serverConfig, filePath) {
237
+ const url = serverConfig?.transport === "hub-url"
238
+ ? resolveHubUrl()
239
+ : normalizeUrl(serverConfig?.url || "");
240
+ const basenameValue = pathBasename(filePath);
241
+
242
+ if (basenameValue === ".mcp.json") {
243
+ return { name, config: { type: "url", url } };
244
+ }
245
+
246
+ if (isCodexConfig(filePath)) {
247
+ return { name, config: { url } };
248
+ }
249
+
250
+ return { name, config: { url } };
251
+ }
252
+
253
+ function scanJsonConfig(filePath) {
254
+ if (!existsSync(filePath)) {
255
+ return {
256
+ filePath,
257
+ client: detectClient(filePath),
258
+ label: detectLabel(filePath),
259
+ exists: false,
260
+ parseError: null,
261
+ servers: [],
262
+ stdioServers: [],
263
+ };
264
+ }
265
+
266
+ try {
267
+ const parsed = readJsonFile(filePath);
268
+ const mcpServers = parsed?.mcpServers;
269
+ const servers = !mcpServers || typeof mcpServers !== "object"
270
+ ? []
271
+ : Object.entries(mcpServers)
272
+ .filter(([name, config]) => typeof name === "string" && config && typeof config === "object")
273
+ .map(([name, config]) => ({
274
+ name: name.trim(),
275
+ url: typeof config.url === "string" ? normalizeUrl(config.url) : "",
276
+ command: typeof config.command === "string" ? config.command : "",
277
+ type: typeof config.type === "string" ? config.type : "",
278
+ transport: typeof config.url === "string" && config.url
279
+ ? "url"
280
+ : typeof config.command === "string" && config.command
281
+ ? "stdio"
282
+ : "unknown",
283
+ raw: config,
284
+ }));
285
+
286
+ return {
287
+ filePath,
288
+ client: detectClient(filePath),
289
+ label: detectLabel(filePath),
290
+ exists: true,
291
+ parseError: null,
292
+ servers,
293
+ stdioServers: servers.filter((server) => server.transport === "stdio"),
294
+ };
295
+ } catch (error) {
296
+ return {
297
+ filePath,
298
+ client: detectClient(filePath),
299
+ label: detectLabel(filePath),
300
+ exists: true,
301
+ parseError: error,
302
+ servers: [],
303
+ stdioServers: [],
304
+ };
305
+ }
306
+ }
307
+
308
+ function scanCodexConfig(filePath) {
309
+ if (!existsSync(filePath)) {
310
+ return {
311
+ filePath,
312
+ client: detectClient(filePath),
313
+ label: detectLabel(filePath),
314
+ exists: false,
315
+ parseError: null,
316
+ servers: [],
317
+ stdioServers: [],
318
+ };
319
+ }
320
+
321
+ try {
322
+ const raw = readFileSync(filePath, "utf8");
323
+ const parsed = parseCodexMcpServers(raw);
324
+ const servers = Object.entries(parsed).map(([name, config]) => ({
325
+ name,
326
+ url: typeof config.url === "string" ? normalizeUrl(config.url) : "",
327
+ command: typeof config.command === "string" ? config.command : "",
328
+ transport: typeof config.url === "string" && config.url
329
+ ? "url"
330
+ : typeof config.command === "string" && config.command
331
+ ? "stdio"
332
+ : "unknown",
333
+ raw: config,
334
+ }));
335
+
336
+ return {
337
+ filePath,
338
+ client: detectClient(filePath),
339
+ label: detectLabel(filePath),
340
+ exists: true,
341
+ parseError: null,
342
+ servers,
343
+ // Wave 1/2-B: Codex stdio servers are observed but not auto-remediated.
344
+ stdioServers: [],
345
+ };
346
+ } catch (error) {
347
+ return {
348
+ filePath,
349
+ client: detectClient(filePath),
350
+ label: detectLabel(filePath),
351
+ exists: true,
352
+ parseError: error,
353
+ servers: [],
354
+ stdioServers: [],
355
+ };
356
+ }
357
+ }
358
+
359
+ function updateJsonConfig(filePath, updates = [], removals = []) {
360
+ const resolvedPath = resolveFilePath(filePath);
361
+ let parsed = {};
362
+
363
+ if (existsSync(resolvedPath)) {
364
+ parsed = readJsonFile(resolvedPath);
365
+ }
366
+
367
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
368
+ parsed = {};
369
+ }
370
+
371
+ if (!parsed.mcpServers || typeof parsed.mcpServers !== "object" || Array.isArray(parsed.mcpServers)) {
372
+ parsed.mcpServers = {};
373
+ }
374
+
375
+ let modified = false;
376
+
377
+ for (const name of removals) {
378
+ if (Object.hasOwn(parsed.mcpServers, name)) {
379
+ delete parsed.mcpServers[name];
380
+ modified = true;
381
+ }
382
+ }
383
+
384
+ for (const update of updates) {
385
+ const current = parsed.mcpServers[update.name];
386
+ const nextConfig = { ...(current && typeof current === "object" ? current : {}), ...update.config };
387
+ const changed = JSON.stringify(current || null) !== JSON.stringify(nextConfig);
388
+ if (changed) {
389
+ parsed.mcpServers[update.name] = nextConfig;
390
+ modified = true;
391
+ }
392
+ }
393
+
394
+ if (!modified) {
395
+ return { modified: false, filePath: resolvedPath };
396
+ }
397
+
398
+ writeJsonFile(resolvedPath, parsed);
399
+ return { modified: true, filePath: resolvedPath };
400
+ }
401
+
402
+ function updateCodexConfig(filePath, updates = [], removals = []) {
403
+ const resolvedPath = resolveFilePath(filePath);
404
+ let raw = existsSync(resolvedPath) ? readFileSync(resolvedPath, "utf8") : "";
405
+
406
+ for (const name of removals) {
407
+ raw = removeTomlSection(raw, name);
408
+ }
409
+
410
+ for (const update of updates) {
411
+ raw = upsertTomlUrlServer(raw, update.name, update.config.url);
412
+ }
413
+
414
+ const finalRaw = raw.trim().length > 0 ? `${raw.trimEnd()}\n` : "";
415
+ const previousRaw = existsSync(resolvedPath) ? readFileSync(resolvedPath, "utf8") : "";
416
+ if (finalRaw === previousRaw) {
417
+ return { modified: false, filePath: resolvedPath };
418
+ }
419
+
420
+ mkdirSync(dirname(resolvedPath), { recursive: true });
421
+ writeFileSync(resolvedPath, finalRaw, "utf8");
422
+ return { modified: true, filePath: resolvedPath };
423
+ }
424
+
425
+ function scanConfig(filePath) {
426
+ const resolvedPath = resolveFilePath(filePath);
427
+ if (isCodexConfig(resolvedPath)) return scanCodexConfig(resolvedPath);
428
+ if (isJsonMcpConfig(resolvedPath)) return scanJsonConfig(resolvedPath);
429
+
430
+ return {
431
+ filePath: resolvedPath,
432
+ client: detectClient(resolvedPath),
433
+ label: detectLabel(resolvedPath),
434
+ exists: existsSync(resolvedPath),
435
+ parseError: null,
436
+ servers: [],
437
+ stdioServers: [],
438
+ };
439
+ }
440
+
441
+ export function getRegistryPath() {
442
+ return REGISTRY_PATH;
443
+ }
444
+
445
+ export function createDefaultRegistry() {
446
+ return cloneDefaultRegistry();
447
+ }
448
+
449
+ export function validateRegistry(registry) {
450
+ const errors = [];
451
+ if (!registry || typeof registry !== "object" || Array.isArray(registry)) {
452
+ return ["registry must be an object"];
453
+ }
454
+
455
+ if (registry.version !== 1) {
456
+ errors.push("registry.version must be 1");
457
+ }
458
+
459
+ if (!registry.defaults || typeof registry.defaults !== "object") {
460
+ errors.push("registry.defaults must be an object");
461
+ }
462
+
463
+ if (!registry.servers || typeof registry.servers !== "object" || Array.isArray(registry.servers)) {
464
+ errors.push("registry.servers must be an object");
465
+ } else {
466
+ for (const [name, server] of Object.entries(registry.servers)) {
467
+ if (!name.trim()) {
468
+ errors.push("registry.servers contains an empty name");
469
+ continue;
470
+ }
471
+ if (!server || typeof server !== "object" || Array.isArray(server)) {
472
+ errors.push(`registry.servers.${name} must be an object`);
473
+ continue;
474
+ }
475
+ if (typeof server.url !== "string" || !server.url.trim()) {
476
+ errors.push(`registry.servers.${name}.url must be a non-empty string`);
477
+ }
478
+ if (server.targets !== undefined && !Array.isArray(server.targets)) {
479
+ errors.push(`registry.servers.${name}.targets must be an array`);
480
+ }
481
+ }
482
+ }
483
+
484
+ if (!registry.policies || typeof registry.policies !== "object" || Array.isArray(registry.policies)) {
485
+ errors.push("registry.policies must be an object");
486
+ } else {
487
+ if (!Array.isArray(registry.policies.watched_paths)) {
488
+ errors.push("registry.policies.watched_paths must be an array");
489
+ }
490
+ if (registry.policies.stdio_action && !["replace-with-hub", "warn"].includes(registry.policies.stdio_action)) {
491
+ errors.push("registry.policies.stdio_action must be replace-with-hub or warn");
492
+ }
493
+ }
494
+
495
+ return errors;
496
+ }
497
+
498
+ export function inspectRegistry() {
499
+ if (!existsSync(REGISTRY_PATH)) {
500
+ return {
501
+ path: REGISTRY_PATH,
502
+ exists: false,
503
+ valid: false,
504
+ errors: ["registry file missing"],
505
+ registry: null,
506
+ };
507
+ }
508
+
509
+ try {
510
+ const registry = readJsonFile(REGISTRY_PATH);
511
+ const errors = validateRegistry(registry);
512
+ return {
513
+ path: REGISTRY_PATH,
514
+ exists: true,
515
+ valid: errors.length === 0,
516
+ errors,
517
+ registry: errors.length === 0 ? registry : null,
518
+ };
519
+ } catch (error) {
520
+ return {
521
+ path: REGISTRY_PATH,
522
+ exists: true,
523
+ valid: false,
524
+ errors: [error.message],
525
+ registry: null,
526
+ };
527
+ }
528
+ }
529
+
530
+ export function loadRegistry() {
531
+ const state = inspectRegistry();
532
+ if (!state.exists) {
533
+ throw new Error(`MCP registry missing: ${state.path}`);
534
+ }
535
+ if (!state.valid) {
536
+ throw new Error(`MCP registry invalid: ${state.errors.join("; ")}`);
537
+ }
538
+ return {
539
+ ...state.registry,
540
+ defaults: { ...(state.registry?.defaults || {}) },
541
+ servers: { ...(state.registry?.servers || {}) },
542
+ policies: { ...(state.registry?.policies || {}) },
543
+ };
544
+ }
545
+
546
+ export function saveRegistry(registry) {
547
+ const errors = validateRegistry(registry);
548
+ if (errors.length > 0) {
549
+ throw new Error(`MCP registry invalid: ${errors.join("; ")}`);
550
+ }
551
+ writeJsonFile(REGISTRY_PATH, registry);
552
+ return registry;
553
+ }
554
+
555
+ export function listManagedConfigTargets(registry = loadRegistry()) {
556
+ return (registry?.policies?.watched_paths || []).map((watchedPath) => {
557
+ const filePath = resolveFilePath(watchedPath);
558
+ return {
559
+ watchedPath,
560
+ filePath,
561
+ client: detectClient(filePath),
562
+ label: detectLabel(filePath),
563
+ exists: existsSync(filePath),
564
+ };
565
+ });
566
+ }
567
+
568
+ export function listPrimaryConfigTargets(registry = loadRegistry()) {
569
+ return listManagedConfigTargets(registry).filter((target) => isPrimaryConfigTarget(target.filePath));
570
+ }
571
+
572
+ export function scanManagedConfigs(registry = loadRegistry()) {
573
+ return listManagedConfigTargets(registry).map((target) => ({
574
+ ...target,
575
+ ...scanConfig(target.filePath),
576
+ }));
577
+ }
578
+
579
+ export function inspectRegistryStatus(registry = loadRegistry()) {
580
+ const configs = scanManagedConfigs(registry);
581
+ const primaryTargets = new Set(listPrimaryConfigTargets(registry).map((target) => normalizeForMatch(target.filePath)));
582
+ const rows = [];
583
+
584
+ for (const config of configs) {
585
+ const isPrimary = primaryTargets.has(normalizeForMatch(config.filePath));
586
+ const managedServers = Object.entries(registry.servers || {})
587
+ .filter(([, serverConfig]) => isPrimary && serverAppliesToClient(serverConfig, config.client));
588
+
589
+ for (const [name, serverConfig] of managedServers) {
590
+ const expectedUrl = buildDesiredServerRecord(name, serverConfig, config.filePath).config.url;
591
+ const actual = config.servers.find((server) => server.name === name) || null;
592
+ let status = "missing";
593
+
594
+ if (!config.exists) {
595
+ status = "missing-file";
596
+ } else if (config.parseError) {
597
+ status = "invalid-config";
598
+ } else if (!actual) {
599
+ status = "missing";
600
+ } else if (!actual.url) {
601
+ status = actual.transport === "stdio" ? "stdio" : "invalid";
602
+ } else if (normalizeUrl(actual.url) === normalizeUrl(expectedUrl)) {
603
+ status = "present";
604
+ } else {
605
+ status = "mismatch";
606
+ }
607
+
608
+ rows.push({
609
+ type: "registry",
610
+ name,
611
+ client: config.client,
612
+ label: config.label,
613
+ filePath: config.filePath,
614
+ expectedUrl,
615
+ actualUrl: actual?.url || "",
616
+ status,
617
+ });
618
+ }
619
+
620
+ for (const server of config.stdioServers) {
621
+ if (Object.hasOwn(registry.servers || {}, server.name)) continue;
622
+ rows.push({
623
+ type: "stdio",
624
+ name: server.name,
625
+ client: config.client,
626
+ label: config.label,
627
+ filePath: config.filePath,
628
+ expectedUrl: "",
629
+ actualUrl: "",
630
+ status: "warning",
631
+ command: server.command,
632
+ });
633
+ }
634
+ }
635
+
636
+ return {
637
+ registry,
638
+ configs,
639
+ rows,
640
+ };
641
+ }
642
+
643
+ export function scanForStdioServers(filePath) {
644
+ return scanConfig(filePath).stdioServers;
645
+ }
646
+
647
+ export function remediate(filePath, stdioServers, policy = {}) {
648
+ const resolvedPath = resolveFilePath(filePath);
649
+ const offenders = Array.isArray(stdioServers) ? stdioServers.filter((server) => server?.name) : [];
650
+ const action = policy?.stdio_action || "warn";
651
+
652
+ if (offenders.length === 0) {
653
+ return {
654
+ action: "noop",
655
+ modified: false,
656
+ backupPath: null,
657
+ removedServers: [],
658
+ warnings: [],
659
+ };
660
+ }
661
+
662
+ if (action === "warn") {
663
+ return {
664
+ action,
665
+ modified: false,
666
+ backupPath: null,
667
+ removedServers: [],
668
+ warnings: [
669
+ `[mcp-guard] stdio MCP 감지: ${offenders.map((server) => server.name).join(", ")}`,
670
+ ],
671
+ };
672
+ }
673
+
674
+ if (isCodexConfig(resolvedPath)) {
675
+ return {
676
+ action,
677
+ modified: false,
678
+ backupPath: null,
679
+ removedServers: [],
680
+ warnings: ["[mcp-guard] Codex TOML 자동 수정은 Wave 2-B 범위 밖입니다."],
681
+ };
682
+ }
683
+
684
+ const snapshot = scanConfig(resolvedPath);
685
+ if (snapshot.parseError) {
686
+ return {
687
+ action,
688
+ modified: false,
689
+ backupPath: null,
690
+ removedServers: [],
691
+ warnings: [`[mcp-guard] 설정 파싱 실패: ${snapshot.parseError.message}`],
692
+ };
693
+ }
694
+
695
+ let backupPath = null;
696
+ try {
697
+ if (existsSync(resolvedPath)) backupPath = ensureBackup(resolvedPath);
698
+ } catch (error) {
699
+ return {
700
+ action,
701
+ modified: false,
702
+ backupPath: null,
703
+ removedServers: [],
704
+ warnings: [`[mcp-guard] 백업 생성 실패: ${error.message}`],
705
+ };
706
+ }
707
+
708
+ const removals = offenders.map((server) => server.name);
709
+ const updates = [];
710
+ let replacement = null;
711
+
712
+ if (action === "replace-with-hub") {
713
+ const registry = loadRegistry();
714
+ const [hubServerName, hubServerConfig] = getHubServerEntry(registry);
715
+ const desired = buildDesiredServerRecord(hubServerName, hubServerConfig, resolvedPath);
716
+ replacement = { name: desired.name, ...desired.config };
717
+ updates.push(desired);
718
+ }
719
+
720
+ const result = updateJsonConfig(resolvedPath, updates, removals);
721
+ return {
722
+ action,
723
+ modified: result.modified,
724
+ backupPath,
725
+ removedServers: removals,
726
+ replacement,
727
+ warnings: [],
728
+ };
729
+ }
730
+
731
+ export function resolveHubUrl() {
732
+ const registryState = inspectRegistry();
733
+ const registry = registryState.valid ? registryState.registry : cloneDefaultRegistry();
734
+ const [, hubServer] = getHubServerEntry(registry);
735
+ const fallbackRaw = hubServer?.url || `${registry?.defaults?.hub_base || "http://127.0.0.1:27888"}${DEFAULT_HUB_PATH}`;
736
+
737
+ let fallback;
738
+ try {
739
+ fallback = new URL(fallbackRaw);
740
+ } catch {
741
+ fallback = new URL(`http://127.0.0.1:27888${DEFAULT_HUB_PATH}`);
742
+ }
743
+
744
+ const envPortRaw = Number(process.env.TFX_HUB_PORT || "");
745
+ const envPort = Number.isFinite(envPortRaw) && envPortRaw > 0 ? envPortRaw : null;
746
+ const target = {
747
+ protocol: fallback.protocol || "http:",
748
+ host: fallback.hostname || "127.0.0.1",
749
+ port: envPort || Number(fallback.port || 27888),
750
+ pathname: fallback.pathname && fallback.pathname !== "/" ? fallback.pathname : DEFAULT_HUB_PATH,
751
+ };
752
+
753
+ const hubPidPath = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
754
+ if (existsSync(hubPidPath)) {
755
+ try {
756
+ const info = readJsonFile(hubPidPath);
757
+ if (!envPort) {
758
+ const pidPort = Number(info?.port);
759
+ if (Number.isFinite(pidPort) && pidPort > 0) target.port = pidPort;
760
+ }
761
+ if (typeof info?.host === "string") {
762
+ const host = info.host.trim();
763
+ if (LOOPBACK_HOSTS.has(host)) target.host = host;
764
+ }
765
+ } catch {
766
+ // pid 파일 파싱 실패 시 registry 기본값 사용
767
+ }
768
+ }
769
+
770
+ const hostPart = target.host.includes(":") ? `[${target.host}]` : target.host;
771
+ return `${target.protocol}//${hostPart}:${target.port}${target.pathname}`;
772
+ }
773
+
774
+ export function isWatchedPath(filePath) {
775
+ const registryState = inspectRegistry();
776
+ const registry = registryState.valid ? registryState.registry : cloneDefaultRegistry();
777
+ const candidate = normalizeForMatch(filePath);
778
+
779
+ return (registry?.policies?.watched_paths || []).some((watchedPath) => {
780
+ if (typeof watchedPath !== "string" || !watchedPath.trim()) return false;
781
+
782
+ const trimmed = watchedPath.trim();
783
+ const expanded = expandHome(trimmed);
784
+
785
+ if (trimmed !== expanded || isAbsolute(expanded)) {
786
+ return candidate === normalizeForMatch(expanded);
787
+ }
788
+
789
+ if (!trimmed.includes("/") && !trimmed.includes("\\")) {
790
+ return pathBasename(candidate) === trimmed.toLowerCase();
791
+ }
792
+
793
+ const suffix = trimmed.replace(/^[.][\\/]/, "").replace(/\\/g, "/").toLowerCase();
794
+ return candidate.endsWith(`/${suffix}`);
795
+ });
796
+ }
797
+
798
+ export function addRegistryServer(name, url, options = {}) {
799
+ const trimmedName = String(name || "").trim();
800
+ const normalizedUrl = normalizeUrl(url);
801
+ if (!trimmedName) throw new Error("server name is required");
802
+ if (!normalizedUrl) throw new Error("server url is required");
803
+
804
+ const registryState = inspectRegistry();
805
+ const registry = registryState.valid ? loadRegistry() : cloneDefaultRegistry();
806
+ const transport = options.transport || (trimmedName === "tfx-hub" ? "hub-url" : "url");
807
+
808
+ registry.servers[trimmedName] = {
809
+ transport,
810
+ url: normalizedUrl,
811
+ safe: options.safe ?? true,
812
+ targets: Array.isArray(options.targets) && options.targets.length > 0
813
+ ? [...new Set(options.targets.map((value) => String(value).trim()).filter(Boolean))]
814
+ : ["claude", "gemini", "codex"],
815
+ description: options.description || `${trimmedName} MCP 서버`,
816
+ };
817
+
818
+ saveRegistry(registry);
819
+ return registry.servers[trimmedName];
820
+ }
821
+
822
+ export function removeRegistryServer(name) {
823
+ const trimmedName = String(name || "").trim();
824
+ if (!trimmedName) throw new Error("server name is required");
825
+
826
+ const registry = loadRegistry();
827
+ const existing = registry.servers[trimmedName] || null;
828
+ if (existing) {
829
+ delete registry.servers[trimmedName];
830
+ saveRegistry(registry);
831
+ }
832
+
833
+ return existing;
834
+ }
835
+
836
+ export function removeServerFromTargets(name, options = {}) {
837
+ const trimmedName = String(name || "").trim();
838
+ if (!trimmedName) throw new Error("server name is required");
839
+
840
+ const registry = options.registry || (inspectRegistry().valid ? loadRegistry() : cloneDefaultRegistry());
841
+ const targetsFilter = Array.isArray(options.targets) && options.targets.length > 0
842
+ ? new Set(options.targets)
843
+ : null;
844
+ const actions = [];
845
+
846
+ for (const target of listManagedConfigTargets(registry)) {
847
+ if (targetsFilter && !targetsFilter.has(target.client)) continue;
848
+
849
+ const snapshot = scanConfig(target.filePath);
850
+ if (snapshot.parseError) {
851
+ actions.push({
852
+ type: "remove",
853
+ name: trimmedName,
854
+ filePath: target.filePath,
855
+ label: target.label,
856
+ status: "invalid-config",
857
+ message: snapshot.parseError.message,
858
+ });
859
+ continue;
860
+ }
861
+
862
+ let result;
863
+ if (isCodexConfig(target.filePath)) {
864
+ result = updateCodexConfig(target.filePath, [], [trimmedName]);
865
+ } else if (isJsonMcpConfig(target.filePath)) {
866
+ result = updateJsonConfig(target.filePath, [], [trimmedName]);
867
+ } else {
868
+ continue;
869
+ }
870
+
871
+ actions.push({
872
+ type: "remove",
873
+ name: trimmedName,
874
+ filePath: target.filePath,
875
+ label: target.label,
876
+ status: result.modified ? "removed" : "noop",
877
+ });
878
+ }
879
+
880
+ return { actions };
881
+ }
882
+
883
+ export function syncRegistryTargets(options = {}) {
884
+ const registry = options.registry || loadRegistry();
885
+ const actions = [];
886
+
887
+ for (const target of listManagedConfigTargets(registry)) {
888
+ const snapshot = scanConfig(target.filePath);
889
+ if (snapshot.parseError) {
890
+ actions.push({
891
+ type: "sync",
892
+ filePath: target.filePath,
893
+ label: target.label,
894
+ status: "invalid-config",
895
+ message: snapshot.parseError.message,
896
+ });
897
+ continue;
898
+ }
899
+
900
+ if (snapshot.stdioServers.length > 0) {
901
+ const remediation = remediate(target.filePath, snapshot.stdioServers, registry.policies);
902
+ actions.push({
903
+ type: "remediate",
904
+ filePath: target.filePath,
905
+ label: target.label,
906
+ status: remediation.modified ? "updated" : "warning",
907
+ removedServers: remediation.removedServers,
908
+ replacement: remediation.replacement || null,
909
+ warnings: remediation.warnings || [],
910
+ });
911
+ }
912
+ }
913
+
914
+ for (const target of listPrimaryConfigTargets(registry)) {
915
+ const updates = Object.entries(registry.servers || {})
916
+ .filter(([, serverConfig]) => serverAppliesToClient(serverConfig, target.client))
917
+ .map(([name, serverConfig]) => buildDesiredServerRecord(name, serverConfig, target.filePath));
918
+
919
+ if (updates.length === 0) continue;
920
+
921
+ let result;
922
+ if (isCodexConfig(target.filePath)) {
923
+ result = updateCodexConfig(target.filePath, updates, []);
924
+ } else if (isJsonMcpConfig(target.filePath)) {
925
+ result = updateJsonConfig(target.filePath, updates, []);
926
+ } else {
927
+ continue;
928
+ }
929
+
930
+ actions.push({
931
+ type: "sync",
932
+ filePath: target.filePath,
933
+ label: target.label,
934
+ status: result.modified ? "updated" : "ok",
935
+ serverCount: updates.length,
936
+ });
937
+ }
938
+
939
+ return { actions };
940
+ }