triflux 9.5.1 → 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.
Files changed (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/triflux.mjs +498 -0
  3. package/hooks/hook-registry.json +24 -2
  4. package/hooks/mcp-config-watcher.mjs +85 -0
  5. package/hub/team/headless.mjs +8 -1
  6. package/hub/team/psmux.mjs +24 -4
  7. package/package.json +1 -1
  8. package/scripts/__tests__/mcp-guard-engine.test.mjs +118 -0
  9. package/scripts/headless-guard.mjs +1 -1
  10. package/scripts/lib/mcp-guard-engine.mjs +940 -0
  11. package/scripts/mcp-safety-guard.mjs +44 -0
  12. package/scripts/setup.mjs +71 -0
  13. package/scripts/tfx-route.sh +17 -5
  14. package/skills/tfx-analysis/SKILL.md +4 -0
  15. package/skills/tfx-auto/SKILL.md +4 -0
  16. package/skills/tfx-auto-codex/SKILL.md +4 -0
  17. package/skills/tfx-autopilot/SKILL.md +4 -0
  18. package/skills/tfx-autoresearch/SKILL.md +4 -0
  19. package/skills/tfx-autoroute/SKILL.md +4 -0
  20. package/skills/tfx-codex/SKILL.md +4 -0
  21. package/skills/tfx-codex-swarm/SKILL.md +33 -2
  22. package/skills/tfx-consensus/SKILL.md +4 -0
  23. package/skills/tfx-debate/SKILL.md +4 -0
  24. package/skills/tfx-deep-analysis/SKILL.md +4 -0
  25. package/skills/tfx-deep-interview/SKILL.md +4 -0
  26. package/skills/tfx-deep-plan/SKILL.md +4 -0
  27. package/skills/tfx-deep-qa/SKILL.md +4 -0
  28. package/skills/tfx-deep-research/SKILL.md +4 -0
  29. package/skills/tfx-deep-review/SKILL.md +4 -0
  30. package/skills/tfx-doctor/SKILL.md +3 -0
  31. package/skills/tfx-find/SKILL.md +4 -0
  32. package/skills/tfx-forge/SKILL.md +4 -0
  33. package/skills/tfx-fullcycle/SKILL.md +4 -0
  34. package/skills/tfx-gemini/SKILL.md +4 -0
  35. package/skills/tfx-hub/SKILL.md +4 -0
  36. package/skills/tfx-index/SKILL.md +4 -0
  37. package/skills/tfx-interview/SKILL.md +4 -0
  38. package/skills/tfx-multi/SKILL.md +4 -0
  39. package/skills/tfx-panel/SKILL.md +4 -0
  40. package/skills/tfx-persist/SKILL.md +4 -0
  41. package/skills/tfx-plan/SKILL.md +4 -0
  42. package/skills/tfx-prune/SKILL.md +4 -0
  43. package/skills/tfx-qa/SKILL.md +4 -0
  44. package/skills/tfx-ralph/SKILL.md +4 -0
  45. package/skills/tfx-remote-setup/SKILL.md +4 -0
  46. package/skills/tfx-remote-spawn/SKILL.md +4 -0
  47. package/skills/tfx-research/SKILL.md +4 -0
  48. package/skills/tfx-review/SKILL.md +4 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "9.5.1",
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("");
@@ -1766,6 +1916,181 @@ async function cmdDoctor(options = {}) {
1766
1916
  ok("잔존 팀 없음");
1767
1917
  }
1768
1918
 
1919
+ // ── Docs 동기화 상태 ──
1920
+ section("Docs Sync");
1921
+ {
1922
+ const docsDirs = ["docs/design", "docs/research"];
1923
+ const missingDocs = [];
1924
+ for (const dir of docsDirs) {
1925
+ const src = join(PKG_ROOT, dir);
1926
+ const dest = join(CLAUDE_DIR, dir);
1927
+ if (existsSync(src)) {
1928
+ const srcFiles = readdirSync(src).filter(f => f.endsWith(".md"));
1929
+ if (!existsSync(dest)) {
1930
+ missingDocs.push({ dir, missing: srcFiles.length, detail: "디렉토리 없음" });
1931
+ } else {
1932
+ const destFiles = readdirSync(dest).filter(f => f.endsWith(".md"));
1933
+ const missing = srcFiles.filter(f => !destFiles.includes(f));
1934
+ if (missing.length > 0) missingDocs.push({ dir, missing: missing.length, detail: missing.join(", ") });
1935
+ }
1936
+ }
1937
+ }
1938
+ if (missingDocs.length === 0) {
1939
+ addDoctorCheck(report, { name: "docs-sync", status: "ok" });
1940
+ ok("레퍼런스 문서 동기화 정상");
1941
+ } else {
1942
+ addDoctorCheck(report, { name: "docs-sync", status: "issues", missingDocs, fix: "tfx setup" });
1943
+ warn(`${missingDocs.reduce((s, d) => s + d.missing, 0)}개 레퍼런스 미동기화`);
1944
+ for (const d of missingDocs) info(`${d.dir}: ${d.detail}`);
1945
+ if (fix) {
1946
+ for (const dir of docsDirs) {
1947
+ const src = join(PKG_ROOT, dir);
1948
+ const dest = join(CLAUDE_DIR, dir);
1949
+ if (existsSync(src)) {
1950
+ mkdirSync(dest, { recursive: true });
1951
+ for (const f of readdirSync(src).filter(f => f.endsWith(".md"))) {
1952
+ copyFileSync(join(src, f), join(dest, f));
1953
+ }
1954
+ }
1955
+ }
1956
+ ok("레퍼런스 동기화 완료");
1957
+ } else {
1958
+ issues += missingDocs.length;
1959
+ }
1960
+ }
1961
+ }
1962
+
1963
+ // ── MCP 중앙 레지스트리 ──
1964
+ section("MCP Registry");
1965
+ {
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})` : ""}`);
2051
+ }
2052
+ }
2053
+
2054
+ issues += invalidConfigs.length;
2055
+ issues += mismatchRows.length;
2056
+ issues += stdioRows.length;
2057
+ }
2058
+ }
2059
+
2060
+ // ── Route Script 정합성 ──
2061
+ section("Route Script Sync");
2062
+ {
2063
+ const srcRoute = join(PKG_ROOT, "scripts", "tfx-route.sh");
2064
+ const destRoute = join(CLAUDE_DIR, "scripts", "tfx-route.sh");
2065
+ if (existsSync(srcRoute) && existsSync(destRoute)) {
2066
+ const srcHash = readFileSync(srcRoute, "utf8").length;
2067
+ const destHash = readFileSync(destRoute, "utf8").length;
2068
+ const srcContent = readFileSync(srcRoute, "utf8");
2069
+ const destContent = readFileSync(destRoute, "utf8");
2070
+ if (srcContent === destContent) {
2071
+ addDoctorCheck(report, { name: "route-sync", status: "ok" });
2072
+ ok("프로젝트 소스와 설치본 일치");
2073
+ } else {
2074
+ addDoctorCheck(report, { name: "route-sync", status: "issues", fix: "tfx setup" });
2075
+ warn("tfx-route.sh 프로젝트 소스와 설치본 불일치");
2076
+ info(`소스: ${srcRoute} (${srcHash}B) / 설치: ${destRoute} (${destHash}B)`);
2077
+ if (fix) {
2078
+ copyFileSync(srcRoute, destRoute);
2079
+ ok("tfx-route.sh 동기화 완료");
2080
+ } else {
2081
+ issues++;
2082
+ }
2083
+ }
2084
+ } else if (existsSync(srcRoute) && !existsSync(destRoute)) {
2085
+ addDoctorCheck(report, { name: "route-sync", status: "missing", fix: "tfx setup" });
2086
+ fail("설치본 없음");
2087
+ issues++;
2088
+ } else {
2089
+ addDoctorCheck(report, { name: "route-sync", status: "ok" });
2090
+ ok("소스 없음 (npm 패키지 모드)");
2091
+ }
2092
+ }
2093
+
1769
2094
  // 결과
1770
2095
  console.log(`\n ${LINE}`);
1771
2096
  if (issues === 0) {
@@ -2089,6 +2414,175 @@ function cmdSchema(args = []) {
2089
2414
  });
2090
2415
  }
2091
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
+
2092
2586
  function checkForUpdate() {
2093
2587
  const cacheFile = join(CLAUDE_DIR, "cache", "triflux-update-check.json");
2094
2588
  const cacheDir = dirname(cacheFile);
@@ -2141,6 +2635,7 @@ ${updateNotice}
2141
2635
  ${DIM} --fix${RESET} ${GRAY}진단 + 자동 수정${RESET}
2142
2636
  ${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
2143
2637
  ${DIM} --json${RESET} ${GRAY}구조화된 진단 결과 JSON 출력${RESET}
2638
+ ${WHITE_BRIGHT}tfx mcp${RESET} ${GRAY}MCP registry 관리 (list/sync/add/remove)${RESET}
2144
2639
  ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 안정 버전으로 업데이트${RESET}
2145
2640
  ${DIM} --dev / dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
2146
2641
  ${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
@@ -2643,6 +3138,9 @@ async function main() {
2643
3138
  await cmdDoctor({ fix, reset, json: JSON_OUTPUT });
2644
3139
  return;
2645
3140
  }
3141
+ case "mcp":
3142
+ cmdMcp(cmdArgs, { json: JSON_OUTPUT });
3143
+ return;
2646
3144
  case "schema":
2647
3145
  cmdSchema(cmdArgs);
2648
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
+ }