triflux 9.6.0 → 9.7.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "9.6.0",
3
+ "version": "9.7.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "author": {
6
6
  "name": "tellang"
package/bin/triflux.mjs CHANGED
@@ -12,6 +12,14 @@ import { forceCleanupTeam } from "../hub/team/nativeProxy.mjs";
12
12
  import { cleanupStaleOmcTeams, inspectStaleOmcTeams } from "../hub/team/staleState.mjs";
13
13
  import { getPipelineStateDbPath } from "../hub/pipeline/state.mjs";
14
14
  import { ensureGeminiProfiles } from "../scripts/lib/gemini-profiles.mjs";
15
+ import {
16
+ addRegistryServer,
17
+ inspectRegistry,
18
+ inspectRegistryStatus,
19
+ removeRegistryServer,
20
+ removeServerFromTargets,
21
+ syncRegistryTargets,
22
+ } from "../scripts/lib/mcp-guard-engine.mjs";
15
23
  import {
16
24
  SYNC_MAP, SKILL_ALIASES, REQUIRED_CODEX_PROFILES, LEGACY_CODEX_MODELS,
17
25
  syncAliasedSkillDir, hasProfileSection, replaceProfileSection,
@@ -113,6 +121,31 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
113
121
  toggle: "특정 훅 활성/비활성 토글: hooks toggle <hookId>",
114
122
  },
115
123
  },
124
+ mcp: {
125
+ usage: "tfx mcp <list|sync|add|remove> [--json]",
126
+ description: "MCP registry 상태 확인 및 중앙 동기화",
127
+ subcommands: {
128
+ list: {
129
+ usage: "tfx mcp list [--json]",
130
+ options: [{ name: "--json", type: "boolean", description: "registry + 실제 설정 상태를 JSON으로 출력" }],
131
+ },
132
+ sync: {
133
+ usage: "tfx mcp sync [--json]",
134
+ options: [{ name: "--json", type: "boolean", description: "동기화 결과를 JSON으로 출력" }],
135
+ },
136
+ add: {
137
+ usage: "tfx mcp add <name> --url <url> [--json]",
138
+ options: [
139
+ { name: "--url", type: "string", description: "등록할 MCP URL" },
140
+ { name: "--json", type: "boolean", description: "등록 결과를 JSON으로 출력" },
141
+ ],
142
+ },
143
+ remove: {
144
+ usage: "tfx mcp remove <name> [--json]",
145
+ options: [{ name: "--json", type: "boolean", description: "제거 결과를 JSON으로 출력" }],
146
+ },
147
+ },
148
+ },
116
149
  hub: {
117
150
  usage: "tfx hub <start|stop|status> [--port N] [--json]",
118
151
  description: "tfx-hub 프로세스 제어",
@@ -908,6 +941,104 @@ function addDoctorCheck(report, entry) {
908
941
  report.checks.push(entry);
909
942
  }
910
943
 
944
+ function formatPathForDisplay(filePath) {
945
+ const value = String(filePath || "").replace(/\\/g, "/");
946
+ const homePath = homedir().replace(/\\/g, "/");
947
+ return value.startsWith(homePath) ? `~${value.slice(homePath.length)}` : value;
948
+ }
949
+
950
+ function renderTable(headers, rows) {
951
+ if (!rows.length) return;
952
+ const widths = headers.map((header, index) => {
953
+ const cellWidths = rows.map((row) => stripAnsi(String(row[index] ?? "")).length);
954
+ return Math.max(stripAnsi(header).length, ...cellWidths);
955
+ });
956
+
957
+ const padCell = (cell, width) => {
958
+ const text = String(cell ?? "");
959
+ return text + " ".repeat(Math.max(0, width - stripAnsi(text).length));
960
+ };
961
+ const formatRow = (row) => row.map((cell, index) => padCell(cell, widths[index])).join(" ");
962
+ console.log(` ${formatRow(headers)}`);
963
+ console.log(` ${widths.map((width) => "─".repeat(width)).join(" ")}`);
964
+ for (const row of rows) {
965
+ console.log(` ${formatRow(row)}`);
966
+ }
967
+ }
968
+
969
+ function getOptionValue(args, optionName) {
970
+ const index = args.indexOf(optionName);
971
+ if (index === -1) return null;
972
+ return args[index + 1] ?? null;
973
+ }
974
+
975
+ function statusBadge(status) {
976
+ switch (status) {
977
+ case "present":
978
+ case "ok":
979
+ case "removed":
980
+ return `${GREEN_BRIGHT}${status}${RESET}`;
981
+ case "updated":
982
+ return `${AMBER}${status}${RESET}`;
983
+ case "missing":
984
+ case "missing-file":
985
+ case "warning":
986
+ return `${YELLOW}${status}${RESET}`;
987
+ case "mismatch":
988
+ case "invalid":
989
+ case "invalid-config":
990
+ return `${RED_BRIGHT}${status}${RESET}`;
991
+ default:
992
+ return status;
993
+ }
994
+ }
995
+
996
+ function buildMcpStatusRows(statusInfo) {
997
+ const registryRows = statusInfo.rows
998
+ .filter((row) => row.type === "registry")
999
+ .map((row) => {
1000
+ let detail = "";
1001
+ if (row.status === "present") detail = row.actualUrl || row.expectedUrl;
1002
+ else if (row.status === "missing") detail = "registry only";
1003
+ else if (row.status === "missing-file") detail = "config missing";
1004
+ else if (row.status === "mismatch") detail = `expected ${row.expectedUrl}`;
1005
+ else if (row.status === "invalid-config") detail = "parse error";
1006
+ else if (row.status === "stdio") detail = "configured as stdio";
1007
+ return [row.name, row.label, statusBadge(row.status), formatPathForDisplay(row.filePath), detail];
1008
+ });
1009
+
1010
+ const stdioRows = statusInfo.rows
1011
+ .filter((row) => row.type === "stdio")
1012
+ .map((row) => [
1013
+ row.name,
1014
+ row.label,
1015
+ statusBadge("warning"),
1016
+ formatPathForDisplay(row.filePath),
1017
+ row.command ? `stdio: ${row.command}` : "stdio MCP",
1018
+ ]);
1019
+
1020
+ return [...registryRows, ...stdioRows];
1021
+ }
1022
+
1023
+ function ensureValidRegistryState() {
1024
+ const registryState = inspectRegistry();
1025
+ if (!registryState.exists) {
1026
+ throw createCliError(`MCP registry missing: ${registryState.path}`, {
1027
+ exitCode: EXIT_CONFIG_ERROR,
1028
+ reason: "configError",
1029
+ fix: "config/mcp-registry.json을 복원하거나 `tfx mcp add <name> --url <url>`로 다시 생성하세요.",
1030
+ });
1031
+ }
1032
+ if (!registryState.valid) {
1033
+ throw createCliError(`MCP registry invalid: ${registryState.errors.join("; ")}`, {
1034
+ exitCode: EXIT_CONFIG_ERROR,
1035
+ reason: "configError",
1036
+ fix: `${registryState.path}의 JSON 구조를 수정하세요.`,
1037
+ });
1038
+ }
1039
+ return registryState;
1040
+ }
1041
+
911
1042
  async function cmdDoctor(options = {}) {
912
1043
  const { fix = false, reset = false, json = false } = options;
913
1044
  const report = {
@@ -1081,6 +1212,25 @@ async function cmdDoctor(options = {}) {
1081
1212
  } catch {
1082
1213
  warn("웜업 캐시 자동 복구 실패");
1083
1214
  }
1215
+ const registryStateForFix = inspectRegistry();
1216
+ if (registryStateForFix.valid) {
1217
+ try {
1218
+ const mcpSync = syncRegistryTargets({ registry: registryStateForFix.registry });
1219
+ const updatedCount = mcpSync.actions.filter((action) => action.status === "updated").length;
1220
+ const invalidCount = mcpSync.actions.filter((action) => action.status === "invalid-config").length;
1221
+ report.actions.push({ type: "mcp-sync", status: invalidCount > 0 ? "issues" : "ok", actions: mcpSync.actions });
1222
+ if (updatedCount > 0) ok(`MCP registry 동기화: ${updatedCount}개 설정 반영됨`);
1223
+ else info("MCP registry: 이미 최신 상태");
1224
+ if (invalidCount > 0) warn(`MCP registry 동기화 건너뜀: parse error ${invalidCount}개`);
1225
+ } catch (error) {
1226
+ report.actions.push({ type: "mcp-sync", status: "failed", message: error.message });
1227
+ warn(`MCP registry 자동 동기화 실패: ${error.message}`);
1228
+ }
1229
+ } else if (registryStateForFix.exists) {
1230
+ warn("MCP registry invalid — auto sync 건너뜀");
1231
+ } else {
1232
+ info("MCP registry 없음 — auto sync 건너뜀");
1233
+ }
1084
1234
  console.log(`\n ${LINE}`);
1085
1235
  info("수정 완료 — 아래 진단 결과를 확인하세요");
1086
1236
  console.log("");
@@ -1810,34 +1960,100 @@ async function cmdDoctor(options = {}) {
1810
1960
  }
1811
1961
  }
1812
1962
 
1813
- // ── Gemini MCP 안전성 ──
1814
- section("Gemini MCP Safety");
1963
+ // ── MCP 중앙 레지스트리 ──
1964
+ section("MCP Registry");
1815
1965
  {
1816
- const geminiSettings = join(homedir(), ".gemini", "settings.json");
1817
- if (existsSync(geminiSettings)) {
1818
- try {
1819
- const gs = JSON.parse(readFileSync(geminiSettings, "utf8"));
1820
- const mcpServers = gs.mcpServers || {};
1821
- const dangerousServers = Object.keys(mcpServers).filter(name => {
1822
- const s = mcpServers[name];
1823
- return s.command && !s.url && name !== "tfx-hub";
1824
- });
1825
- if (dangerousServers.length === 0) {
1826
- addDoctorCheck(report, { name: "gemini-mcp-safety", status: "ok" });
1827
- ok("stdio MCP 없음 (spawn EPERM 안전)");
1828
- } else {
1829
- addDoctorCheck(report, { name: "gemini-mcp-safety", status: "warning", servers: dangerousServers, fix: "~/.gemini/settings.json에서 stdio MCP 제거" });
1830
- warn(`${dangerousServers.length}개 stdio MCP 감지 — Gemini stall 위험`);
1831
- for (const s of dangerousServers) info(` ${s}`);
1832
- issues++;
1966
+ const registryState = inspectRegistry();
1967
+ if (!registryState.exists) {
1968
+ addDoctorCheck(report, {
1969
+ name: "mcp-registry",
1970
+ status: "missing",
1971
+ path: registryState.path,
1972
+ fix: "config/mcp-registry.json을 복원하거나 `tfx mcp add <name> --url <url>`를 실행하세요.",
1973
+ });
1974
+ warn("mcp-registry.json 없음");
1975
+ info(`path: ${registryState.path}`);
1976
+ issues++;
1977
+ } else if (!registryState.valid) {
1978
+ addDoctorCheck(report, {
1979
+ name: "mcp-registry",
1980
+ status: "invalid",
1981
+ path: registryState.path,
1982
+ errors: registryState.errors,
1983
+ fix: "config/mcp-registry.json 구조를 수정하세요.",
1984
+ });
1985
+ fail("mcp-registry.json invalid");
1986
+ for (const entry of registryState.errors) info(entry);
1987
+ issues++;
1988
+ } else {
1989
+ const statusInfo = inspectRegistryStatus(registryState.registry);
1990
+ const invalidConfigs = statusInfo.configs.filter((config) => config.parseError);
1991
+ const mismatchRows = statusInfo.rows.filter((row) => row.type === "registry" && row.status === "mismatch");
1992
+ const missingRows = statusInfo.rows.filter((row) => row.type === "registry" && row.status === "missing");
1993
+ const missingFileRows = statusInfo.rows.filter((row) => row.type === "registry" && row.status === "missing-file");
1994
+ const stdioRows = statusInfo.rows.filter((row) => row.type === "stdio");
1995
+ const hasHardIssues = invalidConfigs.length > 0 || mismatchRows.length > 0;
1996
+ const status = hasHardIssues
1997
+ ? "issues"
1998
+ : stdioRows.length > 0
1999
+ ? "warning"
2000
+ : "ok";
2001
+
2002
+ addDoctorCheck(report, {
2003
+ name: "mcp-registry",
2004
+ status,
2005
+ path: registryState.path,
2006
+ server_count: Object.keys(registryState.registry.servers || {}).length,
2007
+ rows: statusInfo.rows,
2008
+ invalid_configs: invalidConfigs.map((config) => ({
2009
+ file: config.filePath,
2010
+ error: config.parseError?.message || "parse error",
2011
+ })),
2012
+ ...(stdioRows.length > 0 ? { fix: "tfx doctor --fix 또는 tfx mcp sync" } : {}),
2013
+ });
2014
+
2015
+ ok(`registry 정상 (${Object.keys(registryState.registry.servers || {}).length}개 server)`);
2016
+
2017
+ if (statusInfo.rows.length > 0) {
2018
+ renderTable(
2019
+ ["server", "target", "status", "config", "detail"],
2020
+ buildMcpStatusRows(statusInfo),
2021
+ );
2022
+ } else {
2023
+ info("등록된 MCP server 없음");
2024
+ }
2025
+
2026
+ for (const config of invalidConfigs) {
2027
+ fail(`${config.label}: 설정 파싱 실패`);
2028
+ info(`${formatPathForDisplay(config.filePath)} — ${config.parseError.message}`);
2029
+ }
2030
+
2031
+ for (const row of mismatchRows) {
2032
+ warn(`${row.label}: ${row.name} URL 불일치`);
2033
+ info(`expected ${row.expectedUrl}`);
2034
+ if (row.actualUrl) info(`actual ${row.actualUrl}`);
2035
+ }
2036
+
2037
+ for (const row of missingFileRows) {
2038
+ info(`${row.label}: ${row.name} 미배치 (${formatPathForDisplay(row.filePath)})`);
2039
+ }
2040
+
2041
+ for (const row of missingRows) {
2042
+ info(`${row.label}: ${row.name} 누락`);
2043
+ }
2044
+
2045
+ if (stdioRows.length === 0) {
2046
+ ok("미등록 stdio MCP 없음");
2047
+ } else {
2048
+ warn(`${stdioRows.length}개 미등록 stdio MCP 감지`);
2049
+ for (const row of stdioRows) {
2050
+ info(`${row.label}: ${row.name}${row.command ? ` (${row.command})` : ""}`);
1833
2051
  }
1834
- } catch {
1835
- addDoctorCheck(report, { name: "gemini-mcp-safety", status: "ok" });
1836
- ok("설정 파일 파싱 불가 — 건너뜀");
1837
2052
  }
1838
- } else {
1839
- addDoctorCheck(report, { name: "gemini-mcp-safety", status: "ok" });
1840
- ok("Gemini 설정 없음 (정상)");
2053
+
2054
+ issues += invalidConfigs.length;
2055
+ issues += mismatchRows.length;
2056
+ issues += stdioRows.length;
1841
2057
  }
1842
2058
  }
1843
2059
 
@@ -2198,6 +2414,175 @@ function cmdSchema(args = []) {
2198
2414
  });
2199
2415
  }
2200
2416
 
2417
+ function cmdMcp(args = [], options = {}) {
2418
+ const { json = false } = options;
2419
+ const sub = String(args[0] || "list").trim().toLowerCase();
2420
+
2421
+ if (sub === "help" || sub === "--help" || sub === "-h") {
2422
+ console.log(`
2423
+ ${AMBER}${BOLD}⬡ tfx mcp${RESET}
2424
+
2425
+ ${WHITE_BRIGHT}tfx mcp list${RESET} ${GRAY}registry + 실제 설정 상태 테이블${RESET}
2426
+ ${WHITE_BRIGHT}tfx mcp sync${RESET} ${GRAY}registry 기준 전체 스캔 + 치환${RESET}
2427
+ ${WHITE_BRIGHT}tfx mcp add <name> --url <url>${RESET} ${GRAY}registry 등록 + 대상 설정 반영${RESET}
2428
+ ${WHITE_BRIGHT}tfx mcp remove <name>${RESET} ${GRAY}registry + 실제 설정에서 제거${RESET}
2429
+ `);
2430
+ return;
2431
+ }
2432
+
2433
+ switch (sub) {
2434
+ case "list": {
2435
+ const registryState = ensureValidRegistryState();
2436
+ const statusInfo = inspectRegistryStatus(registryState.registry);
2437
+ if (json) {
2438
+ printJson({
2439
+ registry_path: registryState.path,
2440
+ server_count: Object.keys(registryState.registry.servers || {}).length,
2441
+ rows: statusInfo.rows,
2442
+ configs: statusInfo.configs.map((config) => ({
2443
+ file: config.filePath,
2444
+ label: config.label,
2445
+ exists: config.exists,
2446
+ parse_error: config.parseError?.message || null,
2447
+ })),
2448
+ });
2449
+ return;
2450
+ }
2451
+
2452
+ console.log(`\n ${AMBER}${BOLD}⬡ triflux mcp${RESET} ${VER}\n`);
2453
+ console.log(` ${LINE}`);
2454
+ section("Registry");
2455
+ info(formatPathForDisplay(registryState.path));
2456
+ ok(`${Object.keys(registryState.registry.servers || {}).length}개 server 등록됨`);
2457
+ if (statusInfo.rows.length === 0) {
2458
+ info("표시할 MCP 상태 없음");
2459
+ } else {
2460
+ renderTable(
2461
+ ["server", "target", "status", "config", "detail"],
2462
+ buildMcpStatusRows(statusInfo),
2463
+ );
2464
+ }
2465
+ console.log("");
2466
+ return;
2467
+ }
2468
+
2469
+ case "sync": {
2470
+ const registryState = ensureValidRegistryState();
2471
+ const result = syncRegistryTargets({ registry: registryState.registry });
2472
+ if (json) {
2473
+ printJson({
2474
+ registry_path: registryState.path,
2475
+ actions: result.actions,
2476
+ });
2477
+ return;
2478
+ }
2479
+
2480
+ console.log(`\n ${AMBER}${BOLD}⬡ triflux mcp sync${RESET} ${VER}\n`);
2481
+ console.log(` ${LINE}`);
2482
+ section("Actions");
2483
+ for (const action of result.actions) {
2484
+ const label = `${action.label} ${DIM}(${formatPathForDisplay(action.filePath)})${RESET}`;
2485
+ if (action.status === "updated") ok(`${label} → updated`);
2486
+ else if (action.status === "warning") warn(`${label} → warning`);
2487
+ else if (action.status === "invalid-config") fail(`${label} → invalid-config`);
2488
+ else info(`${stripAnsi(label)} → ${action.status}`);
2489
+ }
2490
+ console.log("");
2491
+ return;
2492
+ }
2493
+
2494
+ case "add": {
2495
+ const name = String(args[1] || "").trim();
2496
+ const url = getOptionValue(args, "--url");
2497
+ if (!name) {
2498
+ throw createCliError("MCP server name is required", {
2499
+ exitCode: EXIT_ARG_ERROR,
2500
+ reason: "argError",
2501
+ fix: "tfx mcp add <name> --url <url>",
2502
+ });
2503
+ }
2504
+ if (!url) {
2505
+ throw createCliError("MCP server url is required", {
2506
+ exitCode: EXIT_ARG_ERROR,
2507
+ reason: "argError",
2508
+ fix: "tfx mcp add <name> --url <url>",
2509
+ });
2510
+ }
2511
+
2512
+ const normalizedUrl = (() => {
2513
+ try { return new URL(url).toString(); } catch {
2514
+ throw createCliError(`Invalid MCP URL: ${url}`, {
2515
+ exitCode: EXIT_ARG_ERROR,
2516
+ reason: "argError",
2517
+ fix: "http:// 또는 https:// URL을 사용하세요.",
2518
+ });
2519
+ }
2520
+ })();
2521
+
2522
+ const server = addRegistryServer(name, normalizedUrl);
2523
+ const registryState = ensureValidRegistryState();
2524
+ const syncResult = syncRegistryTargets({ registry: registryState.registry });
2525
+ if (json) {
2526
+ printJson({
2527
+ name,
2528
+ server,
2529
+ actions: syncResult.actions,
2530
+ });
2531
+ return;
2532
+ }
2533
+
2534
+ console.log(`\n ${AMBER}${BOLD}⬡ triflux mcp add${RESET} ${VER}\n`);
2535
+ console.log(` ${LINE}`);
2536
+ ok(`${name} 등록됨`);
2537
+ info(normalizedUrl);
2538
+ const updated = syncResult.actions.filter((action) => action.status === "updated").length;
2539
+ info(`동기화 반영: ${updated}개`);
2540
+ console.log("");
2541
+ return;
2542
+ }
2543
+
2544
+ case "remove": {
2545
+ const name = String(args[1] || "").trim();
2546
+ if (!name) {
2547
+ throw createCliError("MCP server name is required", {
2548
+ exitCode: EXIT_ARG_ERROR,
2549
+ reason: "argError",
2550
+ fix: "tfx mcp remove <name>",
2551
+ });
2552
+ }
2553
+
2554
+ ensureValidRegistryState();
2555
+ const removed = removeRegistryServer(name);
2556
+ const cleanup = removeServerFromTargets(name, { targets: removed?.targets });
2557
+ if (json) {
2558
+ printJson({
2559
+ name,
2560
+ removed: Boolean(removed),
2561
+ server: removed,
2562
+ actions: cleanup.actions,
2563
+ });
2564
+ return;
2565
+ }
2566
+
2567
+ console.log(`\n ${AMBER}${BOLD}⬡ triflux mcp remove${RESET} ${VER}\n`);
2568
+ console.log(` ${LINE}`);
2569
+ if (removed) ok(`${name} registry에서 제거됨`);
2570
+ else warn(`${name} registry entry 없음`);
2571
+ const changed = cleanup.actions.filter((action) => action.status === "removed").length;
2572
+ info(`설정 제거 반영: ${changed}개`);
2573
+ console.log("");
2574
+ return;
2575
+ }
2576
+
2577
+ default:
2578
+ throw createCliError(`알 수 없는 mcp 서브커맨드: ${sub}`, {
2579
+ exitCode: EXIT_ARG_ERROR,
2580
+ reason: "argError",
2581
+ fix: "tfx mcp help",
2582
+ });
2583
+ }
2584
+ }
2585
+
2201
2586
  function checkForUpdate() {
2202
2587
  const cacheFile = join(CLAUDE_DIR, "cache", "triflux-update-check.json");
2203
2588
  const cacheDir = dirname(cacheFile);
@@ -2250,6 +2635,7 @@ ${updateNotice}
2250
2635
  ${DIM} --fix${RESET} ${GRAY}진단 + 자동 수정${RESET}
2251
2636
  ${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
2252
2637
  ${DIM} --json${RESET} ${GRAY}구조화된 진단 결과 JSON 출력${RESET}
2638
+ ${WHITE_BRIGHT}tfx mcp${RESET} ${GRAY}MCP registry 관리 (list/sync/add/remove)${RESET}
2253
2639
  ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 안정 버전으로 업데이트${RESET}
2254
2640
  ${DIM} --dev / dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
2255
2641
  ${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
@@ -2752,6 +3138,9 @@ async function main() {
2752
3138
  await cmdDoctor({ fix, reset, json: JSON_OUTPUT });
2753
3139
  return;
2754
3140
  }
3141
+ case "mcp":
3142
+ cmdMcp(cmdArgs, { json: JSON_OUTPUT });
3143
+ return;
2755
3144
  case "schema":
2756
3145
  cmdSchema(cmdArgs);
2757
3146
  return;
@@ -65,6 +65,17 @@
65
65
  "timeout": 3,
66
66
  "blocking": false,
67
67
  "description": "파일 수정 추적 → 교차 리뷰 nudge"
68
+ },
69
+ {
70
+ "id": "tfx-mcp-config-watcher",
71
+ "source": "triflux",
72
+ "matcher": "Edit|Write",
73
+ "command": "node \"${PLUGIN_ROOT}/hooks/mcp-config-watcher.mjs\"",
74
+ "priority": 1,
75
+ "enabled": true,
76
+ "timeout": 3,
77
+ "blocking": false,
78
+ "description": "감시 대상 MCP 설정 변경 감지 → stdio MCP 자동 치환"
68
79
  }
69
80
  ],
70
81
  "PostToolUseFailure": [
@@ -105,12 +116,23 @@
105
116
  "blocking": false,
106
117
  "description": "triflux 환경 초기화"
107
118
  },
119
+ {
120
+ "id": "tfx-mcp-safety-guard",
121
+ "source": "triflux",
122
+ "matcher": "*",
123
+ "command": "node \"${PLUGIN_ROOT}/scripts/mcp-safety-guard.mjs\"",
124
+ "priority": 1,
125
+ "enabled": true,
126
+ "timeout": 3,
127
+ "blocking": false,
128
+ "description": "Gemini stdio MCP 자동 감지 + 제거 (spawn EPERM 방지)"
129
+ },
108
130
  {
109
131
  "id": "tfx-hub-ensure",
110
132
  "source": "triflux",
111
133
  "matcher": "*",
112
134
  "command": "node \"${PLUGIN_ROOT}/scripts/hub-ensure.mjs\"",
113
- "priority": 1,
135
+ "priority": 2,
114
136
  "enabled": true,
115
137
  "timeout": 8,
116
138
  "blocking": false,
@@ -121,7 +143,7 @@
121
143
  "source": "triflux",
122
144
  "matcher": "*",
123
145
  "command": "node \"${PLUGIN_ROOT}/scripts/preflight-cache.mjs\"",
124
- "priority": 2,
146
+ "priority": 3,
125
147
  "enabled": true,
126
148
  "timeout": 5,
127
149
  "blocking": false,
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // hooks/mcp-config-watcher.mjs — PostToolUse:Edit|Write 훅
3
+ //
4
+ // 감시 대상 MCP 설정 파일 변경을 감지해 stdio 서버를 즉시 차단/치환한다.
5
+ // 경로가 watched_paths와 매칭되지 않으면 바로 종료해 일반 편집 성능에 영향이 없도록 한다.
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import {
9
+ isWatchedPath,
10
+ loadRegistry,
11
+ remediate,
12
+ scanForStdioServers,
13
+ } from "../scripts/lib/mcp-guard-engine.mjs";
14
+
15
+ function readStdin() {
16
+ try {
17
+ return readFileSync(0, "utf8");
18
+ } catch {
19
+ return "";
20
+ }
21
+ }
22
+
23
+ function buildSystemMessage(filePath, stdioServers, result) {
24
+ const lines = [`[mcp-guard] 감시 대상 MCP 설정 변경 감지: ${filePath}`];
25
+
26
+ if (result.modified) {
27
+ const actionLabel = result.replacement ? "자동 치환" : "자동 제거";
28
+ lines.push(`[mcp-guard] stdio MCP ${actionLabel}: ${stdioServers.map((server) => server.name).join(", ")}`);
29
+
30
+ if (result.replacement?.name && result.replacement?.url) {
31
+ lines.push(`[mcp-guard] 대체 서버: ${result.replacement.name} -> ${result.replacement.url}`);
32
+ }
33
+
34
+ if (result.backupPath) {
35
+ lines.push(`[mcp-guard] 백업: ${result.backupPath}`);
36
+ }
37
+ }
38
+
39
+ for (const warning of result.warnings || []) {
40
+ lines.push(warning);
41
+ }
42
+
43
+ return lines.length > 0 ? lines.join("\n") : "";
44
+ }
45
+
46
+ function main() {
47
+ const raw = readStdin();
48
+ if (!raw.trim()) process.exit(0);
49
+
50
+ let input;
51
+ try {
52
+ input = JSON.parse(raw);
53
+ } catch {
54
+ process.exit(0);
55
+ }
56
+
57
+ const toolName = input.tool_name || "";
58
+ if (toolName !== "Edit" && toolName !== "Write") process.exit(0);
59
+
60
+ const filePath = input.tool_input?.file_path || "";
61
+ if (!filePath || !isWatchedPath(filePath)) process.exit(0);
62
+
63
+ let registry;
64
+ try {
65
+ registry = loadRegistry();
66
+ } catch {
67
+ process.exit(0);
68
+ }
69
+
70
+ const stdioServers = scanForStdioServers(filePath);
71
+ if (stdioServers.length === 0) process.exit(0);
72
+
73
+ const result = remediate(filePath, stdioServers, registry.policies);
74
+ const systemMessage = buildSystemMessage(filePath, stdioServers, result);
75
+
76
+ if (systemMessage) {
77
+ process.stdout.write(JSON.stringify({ systemMessage }));
78
+ }
79
+ }
80
+
81
+ try {
82
+ main();
83
+ } catch {
84
+ process.exit(0);
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "9.6.0",
3
+ "version": "9.7.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {